Compare commits
29
Commits
15b863ff5e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ec2dc5fd | ||
|
|
4e7f572d93 | ||
|
|
2d1d571b17 | ||
|
|
a079bf406c | ||
|
|
929a735699 | ||
|
|
9195906c2b | ||
|
|
f918297742 | ||
|
|
37517f360b | ||
|
|
d290930759 | ||
|
|
bab5cc5060 | ||
|
|
6c6257b8f9 | ||
|
|
9edf610d59 | ||
|
|
adbc3e0aa4 | ||
|
|
d04b6b74ad | ||
|
|
82db09e6de | ||
|
|
4c61a02c53 | ||
|
|
37b29934ef | ||
|
|
bf437b3b69 | ||
|
|
58bd504333 | ||
|
|
a071422e23 | ||
|
|
3dff435156 | ||
|
|
82edc29dfe | ||
|
|
1649c84f6a | ||
|
|
e2f4e755f1 | ||
|
|
ffe1870bb8 | ||
|
|
ca83bc7e13 | ||
|
|
f1b581d213 | ||
|
|
6b86a6bf91 | ||
|
|
8a7545bf5b |
@@ -21,3 +21,7 @@ deploy/.env
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Xcode
|
||||
**/xcuserdata/
|
||||
DerivedData/
|
||||
|
||||
+57
-1
@@ -1,9 +1,15 @@
|
||||
"""Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer (Cookie), Admin-Pflicht.
|
||||
"""Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer, Admin-Pflicht.
|
||||
|
||||
Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe
|
||||
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
|
||||
und liefert den Benutzer – oder 401.
|
||||
|
||||
Zweiter Weg für die native App: eine Gerätemarke der Reichweite ``app`` im
|
||||
Header ``X-Certain-Token``. Eine App kann kein Cookie-Formular ausfüllen und
|
||||
soll nicht das Passwort auf dem Gerät halten. Die enge ``quick_add``-Marke
|
||||
bleibt davon unberührt – sie gilt weiterhin nur dort, wo
|
||||
``require_quick_token`` sie ausdrücklich verlangt.
|
||||
|
||||
``enforce_account_state`` hängt am zentralen ``/api``-Router und sperrt Benutzer
|
||||
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
|
||||
einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
|
||||
@@ -54,7 +60,57 @@ def _session_user(request: Request, db: Session) -> User | None:
|
||||
return request.state.session_user
|
||||
token = request.cookies.get(get_settings().cookie_name, "")
|
||||
user = sessions.validate(db, token)
|
||||
herkunft = "cookie"
|
||||
if user is None:
|
||||
# Die native App meldet sich mit ihrer Marke statt mit einem Cookie.
|
||||
# Hier und nicht erst in ``get_current_user``, damit auch
|
||||
# ``enforce_account_state`` sie sieht – sonst käme eine App an der
|
||||
# Pflicht zum Passwortwechsel vorbei.
|
||||
user = _app_token_user(request, db)
|
||||
herkunft = "marke"
|
||||
request.state.session_user = user
|
||||
request.state.auth_via = herkunft if user is not None else None
|
||||
return user
|
||||
|
||||
|
||||
def _app_token_user(request: Request, db: Session) -> User | None:
|
||||
"""Benutzer hinter einer App-Marke im Header ``X-Certain-Token``.
|
||||
|
||||
Nur die Reichweite ``app`` zählt hier. Eine ``quick_add``-Marke darf auf
|
||||
diesem Weg NICHT zur Anmeldung werden – sonst wäre die bewusst enge Marke
|
||||
still zu einem Vollzugang geworden.
|
||||
"""
|
||||
roh = (request.headers.get("X-Certain-Token") or "").strip()
|
||||
if not roh:
|
||||
return None
|
||||
marke = (
|
||||
db.query(ApiToken)
|
||||
.filter(ApiToken.token_hash == hash_session_token(roh),
|
||||
ApiToken.scope == TokenScope.app)
|
||||
.first()
|
||||
)
|
||||
if marke is None:
|
||||
return None
|
||||
besitzer = db.get(User, marke.owner_id)
|
||||
if besitzer is None or not besitzer.is_active:
|
||||
return None
|
||||
marke.last_used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return besitzer
|
||||
|
||||
|
||||
def require_cookie_session(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
"""Wie ``get_current_user``, aber NUR mit echter Anmeldung.
|
||||
|
||||
Für Schritte, die eine Marke nicht auslösen darf – allen voran das
|
||||
Ausstellen weiterer Marken.
|
||||
"""
|
||||
user = get_current_user(request, db)
|
||||
if getattr(request.state, "auth_via", None) != "cookie":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Dafür ist eine richtige Anmeldung nötig, keine Gerätemarke",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
+17
-7
@@ -409,7 +409,7 @@ class CategoryGroup(Base):
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -431,7 +431,7 @@ class Category(Base):
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
@@ -813,14 +813,24 @@ class CategorizationRule(Base):
|
||||
|
||||
|
||||
class TokenScope(str, enum.Enum):
|
||||
"""Was eine Gerätemarke darf. Bewusst eng gehalten.
|
||||
"""Was eine Gerätemarke darf.
|
||||
|
||||
Eine Marke liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt.
|
||||
Sie darf deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist
|
||||
kein Datenabfluss möglich.
|
||||
``quick_add`` ist die enge Form und die Voreinstellung. Eine solche Marke
|
||||
liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt. Sie darf
|
||||
deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist kein
|
||||
Datenabfluss möglich.
|
||||
|
||||
``app`` ist die Marke der nativen App und gilt wie eine Anmeldung – eine
|
||||
App muss alles können, was die Weboberfläche kann, und die API kennt
|
||||
sonst nur das Sitzungs-Cookie. Der Klartext liegt allein im Schlüsselbund
|
||||
des Geräts; widerrufen wird die Marke in den Einstellungen. Vergeben darf
|
||||
sie nur eine echte Anmeldung, nie eine Marke selbst: Sonst hätte ein Dieb
|
||||
sich beliebig viele Ersatzschlüssel ausgestellt und das Widerrufen wäre
|
||||
wirkungslos.
|
||||
"""
|
||||
|
||||
quick_add = "quick_add"
|
||||
app = "app"
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
@@ -1302,7 +1312,7 @@ class Tag(Base):
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
@@ -46,7 +46,19 @@ def get_template(db: Session = Depends(get_db), user: User = Depends(get_current
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"lines": [{"category_id": ln.category_id, "planned_cents": ln.planned_cents} for ln in lines],
|
||||
# Betriebsart und Zuschlag gehören dazu: PUT gibt sie zurück und die
|
||||
# Monatsansicht liefert sie ohnehin. Ohne sie zeigt ein Client eine
|
||||
# berechnete Zeile als feste an und schreibt sie beim nächsten
|
||||
# Speichern auch als feste zurück.
|
||||
"lines": [
|
||||
{
|
||||
"category_id": ln.category_id,
|
||||
"planned_cents": ln.planned_cents,
|
||||
"modus": ln.modus,
|
||||
"zuschlag_cents": ln.zuschlag_cents,
|
||||
}
|
||||
for ln in lines
|
||||
],
|
||||
"income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income],
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, get_owned
|
||||
from ..deps import get_current_user, get_owned, require_cookie_session
|
||||
from ..models import (
|
||||
Account,
|
||||
ApiToken,
|
||||
@@ -45,9 +45,14 @@ def list_tokens(db: Session = Depends(get_db), user: User = Depends(get_current_
|
||||
def create_token(
|
||||
payload: ApiTokenCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(require_cookie_session),
|
||||
):
|
||||
"""Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen."""
|
||||
"""Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen.
|
||||
|
||||
Bewusst ``require_cookie_session``: Eine App-Marke darf keine weitere
|
||||
ausstellen. Sonst stellte ein Dieb sich Ersatzschlüssel aus und das
|
||||
Widerrufen der gestohlenen Marke liefe ins Leere.
|
||||
"""
|
||||
if payload.default_account_id is not None:
|
||||
get_owned(db, Account, payload.default_account_id, user)
|
||||
klartext = generate_session_token()
|
||||
@@ -55,6 +60,7 @@ def create_token(
|
||||
owner_id=user.id,
|
||||
name=payload.name.strip(),
|
||||
token_hash=hash_session_token(klartext),
|
||||
scope=payload.scope,
|
||||
default_account_id=payload.default_account_id,
|
||||
)
|
||||
db.add(marke)
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, union
|
||||
from sqlalchemy import or_, select, union
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
@@ -194,18 +194,24 @@ def list_transactions(
|
||||
type: TxnType | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
# Suche und die zwei Filter, die es bisher nur im Browser gab. Die
|
||||
# Weboberfläche lädt alle Buchungen und siebt sie dort; ein Telefon soll
|
||||
# dafür nicht 5000 Zeilen über die Leitung ziehen müssen.
|
||||
q: str | None = None,
|
||||
tag_id: int | None = None,
|
||||
subscription_id: int | None = None,
|
||||
# Großzügiges Limit: die Buchungsliste (DataTable) filtert/sortiert im Browser,
|
||||
# deshalb sollen standardmäßig ALLE Buchungen geladen werden, nicht nur 200.
|
||||
limit: int = Query(default=5000, le=50000),
|
||||
offset: int = 0,
|
||||
):
|
||||
q = db.query(Transaction).filter(Transaction.owner_id == user.id)
|
||||
abfrage = db.query(Transaction).filter(Transaction.owner_id == user.id)
|
||||
if account_id is not None:
|
||||
q = q.filter(
|
||||
abfrage = abfrage.filter(
|
||||
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
|
||||
)
|
||||
if category_id is not None:
|
||||
q = q.filter(Transaction.category_id == category_id)
|
||||
abfrage = abfrage.filter(Transaction.category_id == category_id)
|
||||
if uncategorized:
|
||||
# Verknüpfte Buchungen sind erledigt: Sie gehören zu einem Übertrag und
|
||||
# zählen in keiner Auswertung. Sie hier nach einer Kategorie zu fragen,
|
||||
@@ -214,16 +220,39 @@ def list_transactions(
|
||||
select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id),
|
||||
select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id),
|
||||
)
|
||||
q = q.filter(Transaction.category_id.is_(None),
|
||||
Transaction.type != TxnType.transfer,
|
||||
Transaction.id.notin_(verknuepft))
|
||||
abfrage = abfrage.filter(Transaction.category_id.is_(None),
|
||||
Transaction.type != TxnType.transfer,
|
||||
Transaction.id.notin_(verknuepft))
|
||||
if type is not None:
|
||||
q = q.filter(Transaction.type == type)
|
||||
abfrage = abfrage.filter(Transaction.type == type)
|
||||
if date_from is not None:
|
||||
q = q.filter(Transaction.date >= date_from)
|
||||
abfrage = abfrage.filter(Transaction.date >= date_from)
|
||||
if date_to is not None:
|
||||
q = q.filter(Transaction.date <= date_to)
|
||||
rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
|
||||
abfrage = abfrage.filter(Transaction.date <= date_to)
|
||||
if q:
|
||||
# LIKE statt Volltextindex: Ein Haushalt bleibt im Zehntausenderbereich,
|
||||
# da kostet die Suche nichts Spürbares. Was SQLite dabei nicht kann:
|
||||
# Umlaute gross/klein gleichsetzen – „Müller" findet „müller" nicht.
|
||||
# Die Prozent- und Unterstrich-Zeichen des Suchtexts werden entwertet,
|
||||
# sonst wäre jede Eingabe mit „%" ein Platzhalter statt eines Zeichens.
|
||||
muster = "%" + q.translate(
|
||||
str.maketrans({"\\": "\\\\", "%": "\\%", "_": "\\_"})
|
||||
) + "%"
|
||||
abfrage = abfrage.filter(or_(
|
||||
Transaction.payee.ilike(muster, escape="\\"),
|
||||
Transaction.description.ilike(muster, escape="\\"),
|
||||
Transaction.notes.ilike(muster, escape="\\"),
|
||||
))
|
||||
if tag_id is not None:
|
||||
# Unterabfrage statt Join, wie im uncategorized-Zweig: Ein Join würde
|
||||
# jede Buchung so oft liefern, wie sie Tags trägt.
|
||||
mit_tag = select(TransactionTag.transaction_id).where(
|
||||
TransactionTag.owner_id == user.id, TransactionTag.tag_id == tag_id
|
||||
)
|
||||
abfrage = abfrage.filter(Transaction.id.in_(mit_tag))
|
||||
if subscription_id is not None:
|
||||
abfrage = abfrage.filter(Transaction.subscription_id == subscription_id)
|
||||
rows = abfrage.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
|
||||
base = base_currency(db, user)
|
||||
ccy = _currency_map(db, user)
|
||||
woher = _herkunft(db, user, rows)
|
||||
|
||||
@@ -15,6 +15,7 @@ from .models import (
|
||||
IdentifierKind,
|
||||
ImportStatus,
|
||||
Role,
|
||||
TokenScope,
|
||||
TxnType,
|
||||
)
|
||||
|
||||
@@ -360,7 +361,7 @@ class TagOut(BaseModel):
|
||||
class TagCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=60)
|
||||
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
||||
color: str = "#6b7480"
|
||||
color: str = "#3f9c72"
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
@@ -405,7 +406,7 @@ class CategoryGroupCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
kind: CategoryKind = CategoryKind.expense
|
||||
icon: str = "list"
|
||||
color: str = "#6b7480"
|
||||
color: str = "#3f9c72"
|
||||
|
||||
|
||||
class CategoryGroupUpdate(BaseModel):
|
||||
@@ -419,7 +420,7 @@ class CategoryCreate(BaseModel):
|
||||
group_id: int
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
icon: str = "more-horizontal"
|
||||
color: str = "#6b7480"
|
||||
color: str = "#3f9c72"
|
||||
excluded_from_stats: bool = False
|
||||
spending_is_good: bool = False
|
||||
|
||||
@@ -687,11 +688,15 @@ class FlaggedKeywordOut(BaseModel):
|
||||
class ApiTokenCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
default_account_id: int | None = None
|
||||
# Voreinstellung bleibt die enge Marke: Wer nichts angibt, bekommt keinen
|
||||
# Vollzugang.
|
||||
scope: TokenScope = TokenScope.quick_add
|
||||
|
||||
|
||||
class ApiTokenOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
scope: TokenScope
|
||||
default_account_id: int | None
|
||||
last_used_at: datetime | None
|
||||
created_at: datetime | None
|
||||
|
||||
@@ -217,13 +217,27 @@ def reset_month(db: Session, user: User, label: str) -> None:
|
||||
|
||||
|
||||
def copy_month_to_template(db: Session, user: User, label: str) -> None:
|
||||
"""Setzt die Standard-Vorlage auf die wirksamen Soll-Werte dieses Monats
|
||||
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt."""
|
||||
planned = effective_planned(db, user, label) # {category_id: cents}
|
||||
"""Setzt die Standard-Vorlage auf die Soll-Werte dieses Monats
|
||||
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt.
|
||||
|
||||
Übernommen wird die **Betriebsart**, nicht die gerade herauskommende Zahl:
|
||||
Eine berechnete Zeile soll auch in der Vorlage berechnet bleiben. Vorher
|
||||
fror dieser Weg den Monatswert als festen Betrag ein – der Bezug zu den
|
||||
Verträgen war danach still weg, und niemand sah, wo er geblieben war.
|
||||
"""
|
||||
zeilen = list(_zeilen(db, user, label))
|
||||
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
|
||||
for category_id, cents in planned.items():
|
||||
if cents > 0:
|
||||
db.add(BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=cents))
|
||||
for zeile in zeilen:
|
||||
# Eine feste Null ist nichts Geplantes und gehört nicht in die Vorlage.
|
||||
# Eine berechnete Zeile dagegen schon: Ihre Zahl steht in den
|
||||
# Verträgen, nicht in planned_cents.
|
||||
if zeile.modus != "berechnet" and zeile.planned_cents <= 0:
|
||||
continue
|
||||
db.add(BudgetTemplateLine(
|
||||
owner_id=user.id, category_id=zeile.category_id,
|
||||
planned_cents=zeile.planned_cents, modus=zeile.modus,
|
||||
zuschlag_cents=zeile.zuschlag_cents,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -241,7 +255,13 @@ def copy_template_to_month(db: Session, user: User, label: str) -> BudgetPeriod:
|
||||
db.flush()
|
||||
period.customized = True
|
||||
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
|
||||
period.lines.append(BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents))
|
||||
period.lines.append(BudgetPeriodLine(
|
||||
category_id=tl.category_id, planned_cents=tl.planned_cents,
|
||||
# Wie in ``ensure_customized``: Ohne diese beiden fiele eine
|
||||
# berechnete Zeile auf „fest" zurueck, mit der Zahl, die zufaellig
|
||||
# zuletzt in planned_cents stand.
|
||||
modus=tl.modus, zuschlag_cents=tl.zuschlag_cents,
|
||||
))
|
||||
db.commit()
|
||||
return period
|
||||
|
||||
|
||||
@@ -310,6 +310,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
|
||||
verbraucht: set[int] = set()
|
||||
verknuepft = 0
|
||||
vorschlaege: list[tuple[Transaction, Transaction]] = []
|
||||
# Jedes Paar steht in ``kandidaten`` zweimal, einmal je Richtung. Was
|
||||
# verknüpft wird, fällt über ``verbraucht`` heraus – ein Vorschlag aber
|
||||
# nicht, und so kam dieselbe Zahlung zweimal zum Verbinden angeboten.
|
||||
vorgeschlagen: set[tuple[int, int]] = set()
|
||||
|
||||
def schlage_vor(a: Transaction, b: Transaction) -> None:
|
||||
paar = (min(a.id, b.id), max(a.id, b.id))
|
||||
if paar in vorgeschlagen:
|
||||
return
|
||||
vorgeschlagen.add(paar)
|
||||
vorschlaege.append((a, b))
|
||||
|
||||
for a_id in sorted(kandidaten):
|
||||
if a_id in verbraucht:
|
||||
@@ -321,17 +332,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
|
||||
gegen = _eindeutig(a, offen, kandidaten, verbraucht)
|
||||
if gegen is None:
|
||||
if offen:
|
||||
vorschlaege.append((a, offen[0]))
|
||||
schlage_vor(a, offen[0])
|
||||
continue
|
||||
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
|
||||
if a.category_id is not None or gegen.category_id is not None:
|
||||
vorschlaege.append((a, gegen))
|
||||
schlage_vor(a, gegen)
|
||||
continue
|
||||
if not _hat_hinweis(a, gegen, bezeichner, typen):
|
||||
vorschlaege.append((a, gegen))
|
||||
schlage_vor(a, gegen)
|
||||
continue
|
||||
if nur_vorschlaege:
|
||||
vorschlaege.append((a, gegen))
|
||||
schlage_vor(a, gegen)
|
||||
continue
|
||||
verbinde(db, user, a, gegen, automatisch=True)
|
||||
verbraucht.update((a.id, gegen.id))
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Die Geraetemarke der nativen App.
|
||||
|
||||
Die API kannte bisher nur zwei Zustaende: angemeldet per Sitzungs-Cookie, oder
|
||||
eine enge Kurzbefehl-Marke, die ausschliesslich erfassen darf. Eine native App
|
||||
passt in keinen davon - sie kann kein Cookie-Formular ausfuellen und soll das
|
||||
Passwort nicht auf dem Geraet halten.
|
||||
|
||||
Deshalb die Reichweite ``app``: gilt wie eine Anmeldung, liegt im
|
||||
Schluesselbund, ist einzeln widerrufbar. Die enge Marke bleibt eng - das ist
|
||||
der Punkt, an dem diese Datei am genauesten hinsieht.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
|
||||
def _marke(client, name=None, scope=None):
|
||||
koerper = {"name": name or f"Geraet {secrets.token_hex(3)}"}
|
||||
if scope is not None:
|
||||
koerper["scope"] = scope
|
||||
r = client.post("/api/inbox/tokens", json=koerper)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _mit(token):
|
||||
return {"X-Certain-Token": token}
|
||||
|
||||
|
||||
def test_app_marke_darf_lesen_und_schreiben(admin):
|
||||
"""Der ganze Zweck: Mit der Marke allein, ohne Cookie, muss die App
|
||||
arbeiten koennen."""
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
marke = _marke(admin, scope="app")
|
||||
assert marke["scope"] == "app"
|
||||
|
||||
konto = admin.post("/api/accounts", json={
|
||||
"name": f"Marke {secrets.token_hex(3)}", "type": "girokonto",
|
||||
"currency": "CHF", "opening_balance_cents": 0}).json()
|
||||
|
||||
# Ein frischer Client OHNE Cookie - nur der Header.
|
||||
roh = TestClient(fastapi_app)
|
||||
|
||||
r = roh.get("/api/accounts", headers=_mit(marke["token"]))
|
||||
assert r.status_code == 200, r.text
|
||||
assert konto["id"] in {a["id"] for a in r.json()}
|
||||
|
||||
# Lesen reicht nicht, schreiben muss auch gehen.
|
||||
r = roh.post("/api/transactions", headers=_mit(marke["token"]), json={
|
||||
"type": "expense", "date": "2026-07-14", "account_id": konto["id"],
|
||||
"amount_cents": 1234, "payee": "Mit Marke gebucht"})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
# Und die Budgetansicht, der eigentliche Grund fuer die App.
|
||||
assert roh.get("/api/budget/current", headers=_mit(marke["token"])).status_code == 200
|
||||
|
||||
|
||||
def test_kurzbefehl_marke_bleibt_eng(admin):
|
||||
"""Der wichtigste Test hier. Die alte Marke darf durch die neue Pruefung
|
||||
NICHT still zu einem Vollzugang geworden sein."""
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
marke = _marke(admin) # ohne scope -> quick_add
|
||||
assert marke["scope"] == "quick_add"
|
||||
roh = TestClient(fastapi_app)
|
||||
|
||||
# Ihr eigener Endpunkt geht weiterhin.
|
||||
assert roh.get("/api/quick/accounts", headers=_mit(marke["token"])).status_code == 200
|
||||
|
||||
# Alles andere nicht.
|
||||
for pfad in ("/api/accounts", "/api/transactions", "/api/budget/current",
|
||||
"/api/subscriptions", "/api/categories"):
|
||||
r = roh.get(pfad, headers=_mit(marke["token"]))
|
||||
assert r.status_code == 401, f"{pfad} war mit einer quick_add-Marke erreichbar"
|
||||
|
||||
|
||||
def test_ohne_marke_und_mit_falscher_marke_kommt_niemand_herein(admin):
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
roh = TestClient(fastapi_app)
|
||||
assert roh.get("/api/accounts").status_code == 401
|
||||
assert roh.get("/api/accounts", headers=_mit("gibtesnicht")).status_code == 401
|
||||
assert roh.get("/api/accounts", headers=_mit("")).status_code == 401
|
||||
|
||||
|
||||
def test_fremde_marke_sieht_nur_ihre_eigenen_daten(admin):
|
||||
"""Mandanten-Trennung gilt auch auf dem Marken-Weg."""
|
||||
from conftest import login_ready, new_user
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
meins = admin.post("/api/accounts", json={
|
||||
"name": f"Admin-Konto {secrets.token_hex(3)}", "type": "girokonto",
|
||||
"currency": "CHF", "opening_balance_cents": 0}).json()
|
||||
|
||||
anderer, _ = login_ready(*new_user(admin))
|
||||
fremde_marke = _marke(anderer, scope="app")
|
||||
|
||||
roh = TestClient(fastapi_app)
|
||||
r = roh.get("/api/accounts", headers=_mit(fremde_marke["token"]))
|
||||
assert r.status_code == 200
|
||||
assert meins["id"] not in {a["id"] for a in r.json()}
|
||||
assert roh.get(f"/api/accounts/{meins['id']}",
|
||||
headers=_mit(fremde_marke["token"])).status_code == 404
|
||||
|
||||
|
||||
def test_eine_app_marke_stellt_keine_weitere_aus(admin):
|
||||
"""Sonst stellte sich ein Dieb Ersatzschluessel aus und das Widerrufen der
|
||||
gestohlenen Marke liefe ins Leere."""
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
marke = _marke(admin, scope="app")
|
||||
roh = TestClient(fastapi_app)
|
||||
|
||||
r = roh.post("/api/inbox/tokens", headers=_mit(marke["token"]),
|
||||
json={"name": "Ersatzschluessel", "scope": "app"})
|
||||
assert r.status_code == 403, r.text
|
||||
# Auch keine enge Marke.
|
||||
assert roh.post("/api/inbox/tokens", headers=_mit(marke["token"]),
|
||||
json={"name": "Auch nicht"}).status_code == 403
|
||||
# Lesen der eigenen Markenliste bleibt erlaubt.
|
||||
assert roh.get("/api/inbox/tokens", headers=_mit(marke["token"])).status_code == 200
|
||||
|
||||
|
||||
def test_widerrufen_sperrt_sofort(admin):
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
marke = _marke(admin, scope="app")
|
||||
roh = TestClient(fastapi_app)
|
||||
assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 200
|
||||
|
||||
assert admin.delete(f"/api/inbox/tokens/{marke['id']}").status_code == 204
|
||||
assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 401
|
||||
|
||||
|
||||
def test_marke_merkt_sich_wann_sie_zuletzt_benutzt_wurde(admin):
|
||||
"""Damit man im Nachhinein sieht, welches Geraet noch laeuft - und welches
|
||||
man bedenkenlos widerrufen kann."""
|
||||
from app.main import app as fastapi_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
marke = _marke(admin, scope="app")
|
||||
assert marke["last_used_at"] is None
|
||||
|
||||
TestClient(fastapi_app).get("/api/accounts", headers=_mit(marke["token"]))
|
||||
|
||||
danach = next(t for t in admin.get("/api/inbox/tokens").json() if t["id"] == marke["id"])
|
||||
assert danach["last_used_at"] is not None
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Suche und Filter auf der Buchungsliste: ``q``, ``tag_id``, ``subscription_id``.
|
||||
|
||||
Bisher siebte die Weboberflaeche im Browser und lud dafuer alles, was es gab -
|
||||
das Limit steht nicht umsonst auf 5000. Am Telefon ist das der Unterschied
|
||||
zwischen "laedt" und "benutzbar", deshalb sitzen die drei Filter jetzt im
|
||||
Server.
|
||||
|
||||
Die Datenbank lebt ueber den ganzen Testlauf. Texte deshalb eindeutig
|
||||
wuerfeln, sonst findet die Suche die Daten anderer Tests mit.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
from conftest import login_ready, new_user
|
||||
|
||||
|
||||
def _konto(admin):
|
||||
return admin.post("/api/accounts", json={
|
||||
"name": f"Konto {secrets.token_hex(3)}", "type": "girokonto",
|
||||
"currency": "EUR", "opening_balance_cents": 0}).json()
|
||||
|
||||
|
||||
def _buchung(admin, konto, **felder):
|
||||
r = admin.post("/api/transactions", json={
|
||||
"type": "expense", "date": "2026-09-15", "account_id": konto["id"],
|
||||
"amount_cents": 100 + secrets.randbelow(9000), **felder})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _suche(admin, **filter_):
|
||||
r = admin.get("/api/transactions", params=filter_)
|
||||
assert r.status_code == 200, r.text
|
||||
return [t["id"] for t in r.json()]
|
||||
|
||||
|
||||
def test_suche_findet_empfaenger_verwendungszweck_und_notiz(admin):
|
||||
konto = _konto(admin)
|
||||
wort = f"Zebra{secrets.token_hex(4)}"
|
||||
ueber_empfaenger = _buchung(admin, konto, payee=f"Laden {wort}")
|
||||
ueber_zweck = _buchung(admin, konto, payee="Egal", description=f"Rechnung {wort}")
|
||||
ueber_notiz = _buchung(admin, konto, payee="Egal", notes=f"wegen {wort}")
|
||||
daneben = _buchung(admin, konto, payee="Etwas voellig anderes")
|
||||
|
||||
gefunden = _suche(admin, q=wort)
|
||||
assert set(gefunden) == {ueber_empfaenger["id"], ueber_zweck["id"], ueber_notiz["id"]}
|
||||
assert daneben["id"] not in gefunden
|
||||
|
||||
|
||||
def test_suche_achtet_nicht_auf_gross_und_kleinschreibung(admin):
|
||||
konto = _konto(admin)
|
||||
wort = f"Kiosk{secrets.token_hex(4)}"
|
||||
txn = _buchung(admin, konto, payee=wort.upper())
|
||||
|
||||
assert txn["id"] in _suche(admin, q=wort.lower())
|
||||
|
||||
|
||||
def test_prozentzeichen_im_suchtext_bleibt_ein_zeichen(admin):
|
||||
"""Sonst waere jede Eingabe mit % ein Platzhalter und faende alles."""
|
||||
konto = _konto(admin)
|
||||
marke = secrets.token_hex(4)
|
||||
mit_prozent = _buchung(admin, konto, payee=f"Rabatt%{marke}")
|
||||
ohne = _buchung(admin, konto, payee=f"Rabatt {marke}")
|
||||
|
||||
gefunden = _suche(admin, q=f"Rabatt%{marke}")
|
||||
assert gefunden == [mit_prozent["id"]]
|
||||
assert ohne["id"] not in gefunden
|
||||
|
||||
|
||||
def test_tag_filter_zeigt_nur_buchungen_mit_diesem_tag(admin):
|
||||
konto = _konto(admin)
|
||||
tag = admin.post("/api/tags", json={
|
||||
"name": f"Tag {secrets.token_hex(4)}", "color": "#3f51b5"}).json()
|
||||
mit = _buchung(admin, konto, payee="Mit Etikett")
|
||||
ohne = _buchung(admin, konto, payee="Ohne Etikett")
|
||||
admin.patch(f"/api/transactions/{mit['id']}", json={"tag_ids": [tag["id"]]})
|
||||
|
||||
gefunden = _suche(admin, tag_id=tag["id"])
|
||||
assert gefunden == [mit["id"]]
|
||||
assert ohne["id"] not in gefunden
|
||||
|
||||
|
||||
def test_tag_eines_fremden_nutzers_zeigt_keine_buchungen(admin):
|
||||
"""Mandanten-Isolation: Eine geratene fremde ID darf nichts aufschliessen."""
|
||||
konto = _konto(admin)
|
||||
meine = _buchung(admin, konto, payee="Meine Buchung")
|
||||
|
||||
name, passwort = new_user(admin)
|
||||
fremd, _ = login_ready(name, passwort)
|
||||
fremder_tag = fremd.post("/api/tags", json={
|
||||
"name": f"Fremd {secrets.token_hex(4)}", "color": "#eb6834"}).json()
|
||||
|
||||
gefunden = _suche(admin, tag_id=fremder_tag["id"])
|
||||
assert gefunden == []
|
||||
assert meine["id"] not in gefunden
|
||||
|
||||
|
||||
def test_abo_filter_zeigt_nur_buchungen_dieses_vertrags(admin):
|
||||
konto = _konto(admin)
|
||||
abo = admin.post("/api/subscriptions", json={
|
||||
"name": f"Dienst {secrets.token_hex(4)}", "amount_cents": 999,
|
||||
"currency": "EUR", "interval_months": 1}).json()
|
||||
zugeordnet = _buchung(admin, konto, payee="Monatsbeitrag")
|
||||
frei = _buchung(admin, konto, payee="Einmalkauf")
|
||||
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{zugeordnet['id']}")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
gefunden = _suche(admin, subscription_id=abo["id"])
|
||||
assert gefunden == [zugeordnet["id"]]
|
||||
assert frei["id"] not in gefunden
|
||||
|
||||
|
||||
def test_suche_und_tag_filter_greifen_zusammen(admin):
|
||||
konto = _konto(admin)
|
||||
wort = f"Baumarkt{secrets.token_hex(4)}"
|
||||
tag = admin.post("/api/tags", json={
|
||||
"name": f"Tag {secrets.token_hex(4)}", "color": "#2a78d6"}).json()
|
||||
beides = _buchung(admin, konto, payee=f"{wort} Farbe")
|
||||
nur_text = _buchung(admin, konto, payee=f"{wort} Schrauben")
|
||||
nur_tag = _buchung(admin, konto, payee="Etwas anderes")
|
||||
for txn in (beides, nur_tag):
|
||||
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
|
||||
|
||||
gefunden = _suche(admin, q=wort, tag_id=tag["id"])
|
||||
assert gefunden == [beides["id"]]
|
||||
assert nur_text["id"] not in gefunden and nur_tag["id"] not in gefunden
|
||||
@@ -369,3 +369,96 @@ def test_aufstellung_zeigt_eine_verschobene_buchung_im_neuen_monat(admin):
|
||||
assert not any(r["id"] == tx["id"] for r in auf["rows"])
|
||||
karte2 = admin.get("/api/dashboard/expense-share?group_by=category").json()
|
||||
assert auf["total"] == next(r for r in karte2["rows"] if r["name"] == "Lebensmittel")["amount"]
|
||||
|
||||
|
||||
def test_vorlage_nennt_betriebsart_und_zuschlag(admin):
|
||||
"""Die Vorlage gab bisher nur planned_cents heraus.
|
||||
|
||||
Wer sie so liest, haelt eine berechnete Zeile fuer eine feste - und
|
||||
schreibt sie beim naechsten Speichern auch als feste zurueck. PUT und die
|
||||
Monatsansicht nennen beide Felder laengst; hier fehlten sie schlicht.
|
||||
|
||||
Eine eigene Kategorie statt Lebensmittel: Die Testdatenbank lebt ueber den
|
||||
ganzen Lauf, und eine Vorlagenzeile auf "berechnet" umzustellen wuerde die
|
||||
Nachbartests treffen.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
gruppe = next(g for g in admin.get("/api/categories").json()
|
||||
if g["kind"] == "expense")
|
||||
kat = admin.post("/api/categories", json={
|
||||
"group_id": gruppe["id"], "name": f"Vorlagenart {secrets.token_hex(3)}"}).json()
|
||||
|
||||
r = admin.put(f"/api/budget/template/category/{kat['id']}",
|
||||
params={"planned_cents": 12000, "modus": "berechnet",
|
||||
"zuschlag_cents": 2500})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
zeile = next(l for l in admin.get("/api/budget/template").json()["lines"]
|
||||
if l["category_id"] == kat["id"])
|
||||
assert zeile["planned_cents"] == 12000
|
||||
assert zeile["modus"] == "berechnet"
|
||||
assert zeile["zuschlag_cents"] == 2500
|
||||
|
||||
|
||||
def _frische_kategorie(admin, name):
|
||||
import secrets
|
||||
|
||||
gruppe = next(g for g in admin.get("/api/categories").json()
|
||||
if g["kind"] == "expense")
|
||||
return admin.post("/api/categories", json={
|
||||
"group_id": gruppe["id"], "name": f"{name} {secrets.token_hex(3)}"}).json()["id"]
|
||||
|
||||
|
||||
def test_berechnete_zeile_ueberlebt_den_weg_in_den_monat(admin):
|
||||
"""Vorlage -> Monat darf die Betriebsart nicht verlieren.
|
||||
|
||||
Fiel die Zeile auf "fest" zurueck, stand als Soll die Zahl, die zufaellig
|
||||
zuletzt in planned_cents lag - und der Bezug zu den Vertraegen war weg,
|
||||
ohne dass irgendwo stand, dass er weg ist.
|
||||
"""
|
||||
today = date.today()
|
||||
label = f"{today.year:04d}-{today.month:02d}"
|
||||
kat = _frische_kategorie(admin, "Berechnet-Monat")
|
||||
|
||||
admin.put(f"/api/budget/template/category/{kat}",
|
||||
params={"planned_cents": 0, "modus": "berechnet", "zuschlag_cents": 1500})
|
||||
|
||||
r = admin.post(f"/api/budget/{label}/from-template")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
zeile = next(c for g in r.json()["groups"] for c in g["categories"]
|
||||
if c["category_id"] == kat)
|
||||
assert zeile["modus"] == "berechnet"
|
||||
assert zeile["zuschlag_cents"] == 1500
|
||||
|
||||
|
||||
def test_berechnete_zeile_ueberlebt_den_weg_in_die_vorlage(admin):
|
||||
"""Monat -> Vorlage ebenso: "diesen Monat als Standard festlegen" soll die
|
||||
Betriebsart uebernehmen, nicht die gerade herauskommende Zahl einfrieren."""
|
||||
today = date.today()
|
||||
label = f"{today.year:04d}-{today.month:02d}"
|
||||
kat = _frische_kategorie(admin, "Berechnet-Vorlage")
|
||||
|
||||
admin.put(f"/api/budget/{label}/category/{kat}",
|
||||
params={"planned_cents": 0, "modus": "berechnet", "zuschlag_cents": 2500})
|
||||
|
||||
r = admin.post(f"/api/budget/template/from-month/{label}")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
zeile = next(l for l in r.json()["lines"] if l["category_id"] == kat)
|
||||
assert zeile["modus"] == "berechnet"
|
||||
assert zeile["zuschlag_cents"] == 2500
|
||||
|
||||
|
||||
def test_eine_feste_null_landet_nicht_in_der_vorlage(admin):
|
||||
"""Nichts Geplantes bleibt nichts Geplantes - sonst fuellte sich die
|
||||
Vorlage mit Nullzeilen fuer jede je beruehrte Kategorie."""
|
||||
today = date.today()
|
||||
label = f"{today.year:04d}-{today.month:02d}"
|
||||
kat = _frische_kategorie(admin, "Nullzeile")
|
||||
|
||||
admin.put(f"/api/budget/{label}/category/{kat}", params={"planned_cents": 0})
|
||||
r = admin.post(f"/api/budget/template/from-month/{label}")
|
||||
|
||||
assert all(l["category_id"] != kat for l in r.json()["lines"])
|
||||
|
||||
@@ -354,3 +354,30 @@ def test_vorschlag_auch_wenn_die_gegenseite_tage_spaeter_steht(admin):
|
||||
# Bestaetigen kann der Nutzer es trotzdem.
|
||||
assert admin.post("/api/uebertraege/verbinden",
|
||||
json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200
|
||||
|
||||
|
||||
def test_ein_paar_wird_nur_einmal_vorgeschlagen(admin):
|
||||
"""Jedes Paar steht intern zweimal, einmal je Richtung.
|
||||
|
||||
Ohne Entdopplung saehe der Nutzer dieselbe Zahlung zweimal zum Verbinden
|
||||
angeboten - und wer das zweite anklickt, bekaeme den Fehler "bereits
|
||||
verknuepft" fuer etwas, das er gerade selbst verknuepft hat.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
betrag = 100000 + secrets.randbelow(800000)
|
||||
a = admin.post("/api/accounts", json={
|
||||
"name": f"A {secrets.token_hex(3)}", "currency": "EUR"}).json()
|
||||
b = admin.post("/api/accounts", json={
|
||||
"name": f"B {secrets.token_hex(3)}", "currency": "EUR"}).json()
|
||||
raus = admin.post("/api/transactions", json={
|
||||
"type": "expense", "date": "2026-07-14", "account_id": a["id"],
|
||||
"amount_cents": betrag, "payee": "Umbuchung"}).json()
|
||||
rein = admin.post("/api/transactions", json={
|
||||
"type": "income", "date": "2026-07-14", "account_id": b["id"],
|
||||
"amount_cents": betrag, "payee": "Umbuchung"}).json()
|
||||
|
||||
paare = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"]
|
||||
meins = [p for p in paare
|
||||
if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}]
|
||||
assert len(meins) == 1, f"einmal vorschlagen, nicht {len(meins)}-mal"
|
||||
|
||||
@@ -43,3 +43,23 @@ künftigen Importen. Korrektur-Erkennung: ab 5 Anwendungen und ≥ 1/3 Korrektur
|
||||
Prüf-Meldung in den Einstellungen (Behalten/Entfernen). Auf-/zuklappbares
|
||||
Kategorie-Dropdown mit Suche (ersetzt das native optgroup-Select) in Buchungen
|
||||
und Import-Prüfansicht.
|
||||
|
||||
## Phase 6 – iOS-App 🚧
|
||||
Native App in `ios/`, die sich über die Server-Adresse ans selbe Backend hängt.
|
||||
Anmeldung über eine Gerätemarke der Reichweite `app` (kein Passwort auf dem
|
||||
Gerät, kein 30-Tage-Ablauf, einzeln widerrufbar). Deutsch und Englisch, iPad
|
||||
zweispaltig, offline der letzte Stand mit Datum.
|
||||
|
||||
Stufe 1 ✅: Gerüst, Netzwerkschicht, Anmeldung samt zweitem Faktor und
|
||||
Pflicht-Passwortwechsel, Kontenliste. Stufe 2 ✅: Übersicht, Budget-Monat,
|
||||
Buchungsliste mit Suche, Vertragsliste. Stufe 3 ✅: Buchungen, Budget-Zeilen,
|
||||
Verträge und Konten anlegen, ändern und löschen. Stufe 4 ✅: Arbeitsliste mit
|
||||
Eingang, Übertragsvorschlägen, Gebuchtem ohne Kategorie und Dubletten.
|
||||
Stufe 5 ✅: Kategorien samt Erkennungswörtern, Schlagwörter, Einstellungen,
|
||||
Bankabruf und das Einrichten des zweiten Faktors.
|
||||
Übersetzt fehlerfrei, 59 Tests grün, läuft im Simulator; das Xcode-Projekt
|
||||
entsteht aus `ios/project.yml`, die Modelle werden gegen echte
|
||||
Serverantworten geprüft.
|
||||
Stufen 6–7 (Teilen-Menü und Kurzbefehle, voller CSV-Import) stehen aus.
|
||||
|
||||
**Stand, Entscheidungen und offene Punkte: `ios/UEBERGABE.md`.**
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Einen Vertrag anlegen oder ändern.
|
||||
struct AboFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
case neu
|
||||
case aendern(Abo)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu: return 0
|
||||
case .aendern(let abo): return abo.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let zweck: Zweck
|
||||
var fertig: () -> Void
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
|
||||
@State private var name = ""
|
||||
@State private var betragCents = 0
|
||||
@State private var waehrung = "EUR"
|
||||
@State private var rhythmus: Zahlungsrhythmus = .monatlich
|
||||
@State private var kategorieId: Int?
|
||||
@State private var kontoId: Int?
|
||||
@State private var hatFaelligkeit = false
|
||||
@State private var faelligAm = Date()
|
||||
@State private var erkennungstext = ""
|
||||
@State private var notiz = ""
|
||||
@State private var istAktiv = true
|
||||
@State private var symbol = "refresh-ccw"
|
||||
@State private var farbe = "#6b7480"
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "abo.name"), text: $name)
|
||||
Betragsfeld(titel: String(localized: "abo.betrag"),
|
||||
waehrung: waehrung, cents: $betragCents)
|
||||
Picker(String(localized: "abo.rhythmus"), selection: $rhythmus) {
|
||||
ForEach(Zahlungsrhythmus.allCases) { einer in
|
||||
Text(einer.name).tag(einer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
KategorieFeld(titel: String(localized: "buchung.kategorie"),
|
||||
nachschlag: zustand.nachschlag, art: .expense,
|
||||
auswahl: $kategorieId)
|
||||
KontoFeld(titel: String(localized: "abo.konto"),
|
||||
nachschlag: zustand.nachschlag, auswahl: $kontoId)
|
||||
} footer: {
|
||||
Text(String(localized: "abo.kategorieErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "abo.faelligkeitKennen"), isOn: $hatFaelligkeit)
|
||||
if hatFaelligkeit {
|
||||
DatePicker(String(localized: "abo.naechsteZahlung"),
|
||||
selection: $faelligAm, displayedComponents: .date)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(localized: "abo.erkennungstext"), text: $erkennungstext)
|
||||
} footer: {
|
||||
Text(String(localized: "abo.erkennungErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||
TextField(String(localized: "buchung.notiz"), text: $notiz, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Toggle(String(localized: "abo.laeuftNoch"), isOn: $istAktiv)
|
||||
Button(String(localized: "abo.loeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "abo.beendenStattLoeschen"))
|
||||
}
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(titel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) {
|
||||
Task { await sichere() }
|
||||
}
|
||||
.disabled(!istVollstaendig || vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.confirmationDialog(String(localized: "abo.loeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "abo.loeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "abo.loeschenErklaerung"))
|
||||
}
|
||||
.onAppear(perform: belegeVor)
|
||||
}
|
||||
}
|
||||
|
||||
private var titel: String {
|
||||
switch zweck {
|
||||
case .neu: return String(localized: "abo.neu")
|
||||
case .aendern: return String(localized: "abo.aendern")
|
||||
}
|
||||
}
|
||||
|
||||
private var istVollstaendig: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && betragCents > 0
|
||||
}
|
||||
|
||||
private func belegeVor() {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
|
||||
guard case .aendern(let abo) = zweck else {
|
||||
waehrung = zustand.einstellungen.basiswaehrung
|
||||
return
|
||||
}
|
||||
name = abo.name
|
||||
betragCents = abo.amountCents
|
||||
waehrung = abo.currency
|
||||
rhythmus = Zahlungsrhythmus(rawValue: abo.intervalMonths) ?? .monatlich
|
||||
kategorieId = abo.categoryId
|
||||
kontoId = abo.accountId
|
||||
hatFaelligkeit = abo.nextDue != nil
|
||||
faelligAm = abo.nextDue?.datum ?? Date()
|
||||
erkennungstext = abo.payeePattern ?? ""
|
||||
notiz = abo.notes ?? ""
|
||||
istAktiv = abo.isActive
|
||||
symbol = abo.icon
|
||||
farbe = abo.color
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let faellig = hatFaelligkeit ? Tagesdatum(faelligAm) : nil
|
||||
let sauberName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeAboAn(AboAnlegen(
|
||||
name: sauberName, icon: symbol, color: farbe,
|
||||
amountCents: betragCents, currency: waehrung,
|
||||
intervalMonths: rhythmus.rawValue, categoryId: kategorieId,
|
||||
accountId: kontoId, nextDue: faellig,
|
||||
payeePattern: leerAlsNil(erkennungstext), notes: leerAlsNil(notiz)))
|
||||
case .aendern(let abo):
|
||||
_ = try await client.aendereAbo(abo.id, AboAendern(
|
||||
name: sauberName, icon: symbol, color: farbe,
|
||||
amountCents: betragCents, intervalMonths: rhythmus.rawValue,
|
||||
categoryId: kategorieId, accountId: kontoId, nextDue: faellig,
|
||||
payeePattern: leerAlsNil(erkennungstext), isActive: istAktiv,
|
||||
notes: leerAlsNil(notiz)))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let abo) = zweck else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheAbo(abo.id) }) {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func leerAlsNil(_ text: String) -> String? {
|
||||
let sauber = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return sauber.isEmpty ? nil : sauber
|
||||
}
|
||||
}
|
||||
@@ -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,79 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Benutzername und Passwort – einmal. Danach trägt die Gerätemarke, und
|
||||
/// das Passwort wird nirgends gespeichert.
|
||||
struct AnmeldeAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var benutzer = Ablage.benutzername ?? ""
|
||||
@State private var passwort = ""
|
||||
@FocusState private var feld: Feld?
|
||||
|
||||
private enum Feld { case benutzer, passwort }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "anmeldung.benutzer"), text: $benutzer)
|
||||
.textContentType(.username)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($feld, equals: .benutzer)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { feld = .passwort }
|
||||
|
||||
SecureField(String(localized: "anmeldung.passwort"), text: $passwort)
|
||||
.textContentType(.password)
|
||||
.focused($feld, equals: .passwort)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { melde() }
|
||||
} footer: {
|
||||
Text(String(localized: "anmeldung.erklaerung"))
|
||||
}
|
||||
|
||||
if let meldung = zustand.meldung {
|
||||
Section {
|
||||
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(action: melde) {
|
||||
if zustand.beschaeftigt {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(String(localized: "anmeldung.anmelden"))
|
||||
}
|
||||
}
|
||||
.disabled(bereit == false || zustand.beschaeftigt)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(String(localized: "anmeldung.andererServer"), role: .destructive) {
|
||||
Task { await zustand.meldeAb(adresseBehalten: false) }
|
||||
}
|
||||
} footer: {
|
||||
if let adresse = zustand.serverAdresse {
|
||||
Text(String(format: String(localized: "anmeldung.aktuellerServer"),
|
||||
adresse.host() ?? adresse.absoluteString))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "anmeldung.titel"))
|
||||
.onAppear { feld = benutzer.isEmpty ? .benutzer : .passwort }
|
||||
}
|
||||
}
|
||||
|
||||
private var bereit: Bool {
|
||||
!benutzer.trimmingCharacters(in: .whitespaces).isEmpty && !passwort.isEmpty
|
||||
}
|
||||
|
||||
private func melde() {
|
||||
guard bereit else { return }
|
||||
let name = benutzer.trimmingCharacters(in: .whitespaces)
|
||||
let geheim = passwort
|
||||
passwort = ""
|
||||
Task { await zustand.melde(benutzer: name, passwort: geheim) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Pflicht-Passwortwechsel. Solange der offen ist, sperrt das Backend jeden
|
||||
/// fachlichen Endpunkt mit 403 – auch das Anlegen der Gerätemarke. Deshalb
|
||||
/// steht dieser Bildschirm vor allem anderen.
|
||||
///
|
||||
/// Die Richtlinie des Servers (Mindestlänge, Zeichenarten) prüfen wir hier
|
||||
/// nicht nach. Das Backend kennt sie, und sie doppelt zu pflegen hiesse, sie
|
||||
/// irgendwann unterschiedlich zu haben. Es meldet den Grund im Klartext.
|
||||
struct PasswortWechselAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var alt = ""
|
||||
@State private var neu = ""
|
||||
@State private var wiederholung = ""
|
||||
@FocusState private var feld: Feld?
|
||||
|
||||
private enum Feld { case alt, neu, wiederholung }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
SecureField(String(localized: "passwort.bisher"), text: $alt)
|
||||
.textContentType(.password)
|
||||
.focused($feld, equals: .alt)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { feld = .neu }
|
||||
} footer: {
|
||||
Text(String(localized: "passwort.erklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
SecureField(String(localized: "passwort.neu"), text: $neu)
|
||||
.textContentType(.newPassword)
|
||||
.focused($feld, equals: .neu)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { feld = .wiederholung }
|
||||
|
||||
SecureField(String(localized: "passwort.wiederholen"), text: $wiederholung)
|
||||
.textContentType(.newPassword)
|
||||
.focused($feld, equals: .wiederholung)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { wechsle() }
|
||||
} footer: {
|
||||
if !wiederholung.isEmpty, neu != wiederholung {
|
||||
Text(String(localized: "passwort.stimmtNichtUeberein"))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
if let meldung = zustand.meldung {
|
||||
Section {
|
||||
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(action: wechsle) {
|
||||
if zustand.beschaeftigt {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(String(localized: "passwort.speichern"))
|
||||
}
|
||||
}
|
||||
.disabled(bereit == false || zustand.beschaeftigt)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||
Task { await zustand.meldeAb() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "passwort.titel"))
|
||||
.onAppear { feld = .alt }
|
||||
}
|
||||
}
|
||||
|
||||
private var bereit: Bool {
|
||||
!alt.isEmpty && !neu.isEmpty && neu == wiederholung
|
||||
}
|
||||
|
||||
private func wechsle() {
|
||||
guard bereit else { return }
|
||||
let bisher = alt
|
||||
let frisch = neu
|
||||
Task {
|
||||
await zustand.wechslePasswort(alt: bisher, neu: frisch)
|
||||
alt = ""; neu = ""; wiederholung = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Der allererste Bildschirm: Wo steht dein Certain?
|
||||
///
|
||||
/// Die Adresse wird nicht blind übernommen, sondern mit `GET /api/version`
|
||||
/// geprüft. Das erspart die Sorte Fehlersuche, bei der man sich eine Stunde
|
||||
/// fragt, warum die Anmeldung nicht geht, und am Ende ein Buchstabe fehlte.
|
||||
struct ServerAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var eingabe = ""
|
||||
@FocusState private var imFeld: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "server.platzhalter"), text: $eingabe)
|
||||
.textContentType(.URL)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($imFeld)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { pruefe() }
|
||||
} header: {
|
||||
Text(String(localized: "server.ueberschrift"))
|
||||
} footer: {
|
||||
Text(String(localized: "server.erklaerung"))
|
||||
}
|
||||
|
||||
if let meldung = zustand.meldung {
|
||||
Section {
|
||||
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(action: pruefe) {
|
||||
if zustand.beschaeftigt {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(String(localized: "server.verbinden"))
|
||||
}
|
||||
}
|
||||
.disabled(eingabe.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| zustand.beschaeftigt)
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "server.titel"))
|
||||
.onAppear { imFeld = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func pruefe() {
|
||||
Task { _ = await zustand.pruefeUndMerkeServer(eingabe) }
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Der zweite Faktor. Entweder sechs Ziffern aus der Authenticator-App oder
|
||||
/// ein Wiederherstellungscode der Form `ABCDE-FGHIJ`.
|
||||
///
|
||||
/// Fünf Fehlversuche, dann ist die Zwischensitzung verbraucht und es geht
|
||||
/// zurück zur Anmeldung – so hält es das Backend
|
||||
/// (`backend/app/services/sessions.py`, `MAX_2FA_ATTEMPTS`).
|
||||
struct ZweiFaktorAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var code = ""
|
||||
@State private var mitWiederherstellung = false
|
||||
@FocusState private var imFeld: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(platzhalter, text: $code)
|
||||
.textContentType(mitWiederherstellung ? .none : .oneTimeCode)
|
||||
.keyboardType(mitWiederherstellung ? .asciiCapable : .numberPad)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
.monospaced()
|
||||
.focused($imFeld)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { pruefe() }
|
||||
.onChange(of: code) { _, neu in
|
||||
// Sechs Ziffern aus der App: direkt absenden, das
|
||||
// spart den Griff zur Tastatur.
|
||||
if !mitWiederherstellung, neu.count == 6, neu.allSatisfy(\.isNumber) {
|
||||
pruefe()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "zweifaktor.ueberschrift"))
|
||||
} footer: {
|
||||
Text(mitWiederherstellung
|
||||
? String(localized: "zweifaktor.erklaerungWiederherstellung")
|
||||
: String(localized: "zweifaktor.erklaerungCode"))
|
||||
}
|
||||
|
||||
if let meldung = zustand.meldung {
|
||||
Section {
|
||||
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(action: pruefe) {
|
||||
if zustand.beschaeftigt {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(String(localized: "zweifaktor.bestaetigen"))
|
||||
}
|
||||
}
|
||||
.disabled(code.isEmpty || zustand.beschaeftigt)
|
||||
|
||||
Button(mitWiederherstellung
|
||||
? String(localized: "zweifaktor.dochCode")
|
||||
: String(localized: "zweifaktor.dochWiederherstellung")) {
|
||||
mitWiederherstellung.toggle()
|
||||
code = ""
|
||||
imFeld = true
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(String(localized: "zweifaktor.abbrechen"), role: .destructive) {
|
||||
Task { await zustand.meldeAb() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "zweifaktor.titel"))
|
||||
.onAppear { imFeld = true }
|
||||
}
|
||||
}
|
||||
|
||||
private var platzhalter: String {
|
||||
mitWiederherstellung
|
||||
? String(localized: "zweifaktor.platzhalterWiederherstellung")
|
||||
: String(localized: "zweifaktor.platzhalterCode")
|
||||
}
|
||||
|
||||
private func pruefe() {
|
||||
let eingabe = code.trimmingCharacters(in: .whitespaces)
|
||||
guard !eingabe.isEmpty else { return }
|
||||
Task {
|
||||
await zustand.pruefeZweitenFaktor(
|
||||
code: eingabe, wiederherstellung: mitWiederherstellung)
|
||||
code = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen.
|
||||
/// Einrichten geht in Stufe 1 noch nicht in der App – also sagen wir das
|
||||
/// klar, statt den Nutzer gegen eine Wand laufen zu lassen.
|
||||
struct ZweitenFaktorEinrichtenHinweis: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "zweifaktorEinrichten.titel"),
|
||||
systemImage: "lock.shield")
|
||||
} description: {
|
||||
Text(String(localized: "zweifaktorEinrichten.erklaerung"))
|
||||
} actions: {
|
||||
if let adresse = zustand.serverAdresse {
|
||||
Link(String(localized: "zweifaktorEinrichten.imBrowser"),
|
||||
destination: adresse)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||
Task { await zustand.meldeAb() }
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "zweifaktorEinrichten.titel"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Wo die App gerade steht – und damit, welcher Bildschirm zu sehen ist.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppZustand {
|
||||
|
||||
enum Lage: Equatable {
|
||||
/// Beim Start: Gibt es eine Adresse, gibt es eine Marke, gilt sie noch?
|
||||
case startet
|
||||
/// Keine Server-Adresse hinterlegt.
|
||||
case brauchtServer
|
||||
/// Adresse steht, aber keine gültige Marke.
|
||||
case brauchtAnmeldung
|
||||
/// Passwort stimmt, der Code fehlt noch.
|
||||
case brauchtZweitenFaktor
|
||||
/// Das Konto muss erst sein Passwort wechseln.
|
||||
case brauchtPasswortwechsel
|
||||
/// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen.
|
||||
/// Einrichten geht in Stufe 1 noch nicht in der App.
|
||||
case brauchtZweitenFaktorEinrichtung
|
||||
/// Alles gut.
|
||||
case bereit(Benutzer)
|
||||
}
|
||||
|
||||
private(set) var lage: Lage = .startet
|
||||
private(set) var einstellungen = Einstellungen()
|
||||
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,
|
||||
/// sondern eine Zeile unter dem Formular.
|
||||
var meldung: String?
|
||||
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
|
||||
private(set) var beschaeftigt = false
|
||||
|
||||
// MARK: - Bildschirmsperre
|
||||
|
||||
/// Liegt der Sperrbildschirm über der App?
|
||||
private(set) var gesperrt = false
|
||||
/// Ob eine PIN eingerichtet ist. Als Eigenschaft und nicht direkt aus der
|
||||
/// Ablage gelesen, damit die Ansicht nach dem Einrichten neu zeichnet.
|
||||
private(set) var hatPIN = Ablage.hatPIN
|
||||
private var seitWannWeg: Date?
|
||||
private var fehlversuche = 0
|
||||
|
||||
/// So lange darf man weg sein, ohne erneut zu entsperren.
|
||||
private static let gnadenfrist: TimeInterval = 60
|
||||
/// Danach fliegt die Marke raus und es geht zur Anmeldung. Ohne Grenze
|
||||
/// wäre eine sechsstellige PIN in Ruhe durchprobierbar.
|
||||
private static let maxFehlversuche = 5
|
||||
|
||||
var serverAdresse: URL? { Ablage.serverAdresse }
|
||||
|
||||
// MARK: - Start
|
||||
|
||||
func starte() async {
|
||||
guard let adresse = Ablage.serverAdresse else {
|
||||
lage = .brauchtServer
|
||||
return
|
||||
}
|
||||
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
|
||||
client = neuer
|
||||
// Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm.
|
||||
hatPIN = Ablage.hatPIN
|
||||
gesperrt = hatPIN
|
||||
guard Ablage.marke != nil else {
|
||||
lage = .brauchtAnmeldung
|
||||
return
|
||||
}
|
||||
await ladeEigenenZustand()
|
||||
}
|
||||
|
||||
/// Fragt den Server, wer wir sind. Das ist zugleich die Prüfung, ob die
|
||||
/// Marke noch gilt.
|
||||
private func ladeEigenenZustand() async {
|
||||
guard let client else { return }
|
||||
do {
|
||||
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()
|
||||
lage = .bereit(ich)
|
||||
await ladeNachschlag()
|
||||
} catch APIFehler.nichtAngemeldet {
|
||||
// Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten.
|
||||
Ablage.marke = nil
|
||||
await client.setzeMarke(nil)
|
||||
lage = .brauchtAnmeldung
|
||||
} catch APIFehler.pflichtOffen(.passwortWechseln) {
|
||||
lage = .brauchtPasswortwechsel
|
||||
} catch APIFehler.pflichtOffen(.zweiterFaktor) {
|
||||
lage = .brauchtZweitenFaktorEinrichtung
|
||||
} catch {
|
||||
// Offline beim Start: Wir lassen die App herein und zeigen in den
|
||||
// Ansichten den letzten Stand. Die Marke ist ja da.
|
||||
if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") {
|
||||
lage = .bereit(gemerkt.wert)
|
||||
await ladeNachschlag()
|
||||
} else {
|
||||
meldung = error.localizedDescription
|
||||
lage = .brauchtAnmeldung
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server festlegen
|
||||
|
||||
/// Nimmt, was der Nutzer tippt, und macht daraus eine brauchbare Adresse.
|
||||
/// `budget.example.com` wird zu `https://budget.example.com`.
|
||||
///
|
||||
/// `nonisolated`, weil es reine Textarbeit ist – so lässt es sich ohne
|
||||
/// Hauptthread testen.
|
||||
nonisolated static func deuteAdresse(_ eingabe: String) -> URL? {
|
||||
var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
if !text.contains("://") { text = "https://" + text }
|
||||
// Ein angehängter Pfad hilft nicht – die API hängt immer unter /api.
|
||||
guard var teile = URLComponents(string: text), teile.host?.isEmpty == false else {
|
||||
return nil
|
||||
}
|
||||
teile.path = ""
|
||||
teile.query = nil
|
||||
teile.fragment = nil
|
||||
return teile.url
|
||||
}
|
||||
|
||||
func pruefeUndMerkeServer(_ eingabe: String) async -> Bool {
|
||||
guard let adresse = Self.deuteAdresse(eingabe) else {
|
||||
meldung = String(localized: "server.keineAdresse")
|
||||
return false
|
||||
}
|
||||
beschaeftigt = true
|
||||
defer { beschaeftigt = false }
|
||||
meldung = nil
|
||||
|
||||
let versuch = CertainClient(basis: adresse)
|
||||
do {
|
||||
_ = try await versuch.serverkennung()
|
||||
Ablage.serverAdresse = adresse
|
||||
client = versuch
|
||||
lage = .brauchtAnmeldung
|
||||
return true
|
||||
} catch {
|
||||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Anmelden
|
||||
|
||||
func melde(benutzer: String, passwort: String) async {
|
||||
guard let client else { return }
|
||||
beschaeftigt = true
|
||||
defer { beschaeftigt = false }
|
||||
meldung = nil
|
||||
do {
|
||||
let antwort = try await client.melde(benutzer: benutzer, passwort: passwort)
|
||||
Ablage.benutzername = benutzer
|
||||
if antwort.brauchtZweitenFaktor {
|
||||
lage = .brauchtZweitenFaktor
|
||||
return
|
||||
}
|
||||
await nachDerAnmeldung(antwort.user)
|
||||
} catch {
|
||||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func pruefeZweitenFaktor(code: String, wiederherstellung: Bool) async {
|
||||
guard let client else { return }
|
||||
beschaeftigt = true
|
||||
defer { beschaeftigt = false }
|
||||
meldung = nil
|
||||
do {
|
||||
let antwort = try await client.pruefeZweitenFaktor(
|
||||
code: code, wiederherstellung: wiederherstellung)
|
||||
await nachDerAnmeldung(antwort.user)
|
||||
} catch APIFehler.nichtAngemeldet {
|
||||
// Zu viele Fehlversuche: Das Zwischen-Cookie ist weg.
|
||||
meldung = String(localized: "zweifaktor.verbraucht")
|
||||
lage = .brauchtAnmeldung
|
||||
} catch {
|
||||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Nach geglückter Anmeldung: Marke holen und das Cookie wegwerfen.
|
||||
private func nachDerAnmeldung(_ benutzer: Benutzer?) async {
|
||||
guard let client else { return }
|
||||
|
||||
// Offene Pflichten zuerst – mit ihnen lässt sich keine Marke anlegen,
|
||||
// das Backend sperrt den Endpunkt (`enforce_account_state`).
|
||||
if let benutzer, benutzer.mustChangePassword {
|
||||
lage = .brauchtPasswortwechsel
|
||||
return
|
||||
}
|
||||
if let benutzer, benutzer.mfaSetupRequired {
|
||||
lage = .brauchtZweitenFaktorEinrichtung
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let marke = try await client.legeAppMarkeAn(name: Self.geraetename)
|
||||
guard let klartext = marke.token else {
|
||||
meldung = String(localized: "anmeldung.markeOhneSchluessel")
|
||||
return
|
||||
}
|
||||
Ablage.marke = klartext
|
||||
await client.setzeMarke(klartext)
|
||||
// Ab hier trägt die Marke. Das Cookie hat seinen Zweck erfüllt.
|
||||
await client.vergissCookies()
|
||||
await ladeEigenenZustand()
|
||||
} catch {
|
||||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func wechslePasswort(alt: String, neu: String) async {
|
||||
guard let client else { return }
|
||||
beschaeftigt = true
|
||||
defer { beschaeftigt = false }
|
||||
meldung = nil
|
||||
do {
|
||||
let ich = try await client.wechslePasswort(alt: alt, neu: neu)
|
||||
await nachDerAnmeldung(ich)
|
||||
} catch {
|
||||
meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Abmelden
|
||||
|
||||
/// Meldet ab und räumt auf. Die Marke wird dabei am Server widerrufen –
|
||||
/// sonst bliebe sie dort stehen, bis jemand sie von Hand entfernt.
|
||||
func meldeAb(adresseBehalten: Bool = true) async {
|
||||
if let client, Ablage.marke != nil {
|
||||
// Welche der Marken unsere ist, lässt sich nicht am Klartext
|
||||
// ablesen – am Server liegt nur der Hash. Also über den Namen,
|
||||
// unter dem wir sie angelegt haben.
|
||||
let meine = (try? await client.marken())?.first {
|
||||
$0.scope == "app" && $0.name == Self.geraetename
|
||||
}
|
||||
if let meine { try? await client.widerrufeMarke(id: meine.id) }
|
||||
}
|
||||
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
|
||||
Ablage.raeumeAuf()
|
||||
Ablage.serverAdresse = adresse
|
||||
hatPIN = false
|
||||
gesperrt = false
|
||||
fehlversuche = 0
|
||||
if let adresse {
|
||||
client = CertainClient(basis: adresse)
|
||||
lage = .brauchtAnmeldung
|
||||
} else {
|
||||
client = nil
|
||||
lage = .brauchtServer
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sperren und entsperren
|
||||
|
||||
/// iOS fotografiert die App beim Wegschalten für den App-Umschalter.
|
||||
/// Darum wird sofort gesperrt und nicht erst beim Zurückkommen – sonst
|
||||
/// stünden die Kontostände im Umschalter.
|
||||
func gehtWeg() {
|
||||
guard hatPIN, !gesperrt else { return }
|
||||
seitWannWeg = Date()
|
||||
gesperrt = true
|
||||
}
|
||||
|
||||
/// Kurz weggewischt – etwa für eine eintreffende Nachricht – geht ohne
|
||||
/// Nachfrage wieder auf. Erst nach der Gnadenfrist wird gefragt.
|
||||
func kommtZurueck() {
|
||||
guard let weg = seitWannWeg else { return }
|
||||
seitWannWeg = nil
|
||||
if Date().timeIntervalSince(weg) <= Self.gnadenfrist { gesperrt = false }
|
||||
}
|
||||
|
||||
func entsperrt() {
|
||||
gesperrt = false
|
||||
fehlversuche = 0
|
||||
}
|
||||
|
||||
/// Meldet einen Fehlversuch. Gibt zurück, ob noch Versuche übrig sind;
|
||||
/// beim letzten meldet die App ab.
|
||||
func falschePIN() async -> Bool {
|
||||
fehlversuche += 1
|
||||
guard fehlversuche >= Self.maxFehlversuche else { return true }
|
||||
await meldeAb()
|
||||
return false
|
||||
}
|
||||
|
||||
var verbleibendeVersuche: Int { max(0, Self.maxFehlversuche - fehlversuche) }
|
||||
|
||||
func setzePIN(_ pin: String) {
|
||||
Ablage.setzePIN(pin)
|
||||
hatPIN = true
|
||||
gesperrt = false
|
||||
fehlversuche = 0
|
||||
}
|
||||
|
||||
func aktualisiereEinstellungen() async {
|
||||
guard let client else { return }
|
||||
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
/// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar
|
||||
/// machen, welches Gerät man widerruft.
|
||||
static var geraetename: String {
|
||||
#if canImport(UIKit)
|
||||
let roh = UIDevice.current.name
|
||||
return roh.isEmpty ? "iPhone" : roh
|
||||
#else
|
||||
return "iOS-Gerät"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CertainApp: App {
|
||||
@State private var zustand = AppZustand()
|
||||
@Environment(\.scenePhase) private var phase
|
||||
|
||||
var body: some Scene {
|
||||
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
|
||||
// und Weboberfläche gleich aussehen. „system" heisst: nichts vorgeben.
|
||||
let schema: ColorScheme?
|
||||
switch zustand.einstellungen.erscheinungsbild {
|
||||
case "light": schema = .light
|
||||
case "dark": schema = .dark
|
||||
default: schema = nil
|
||||
}
|
||||
|
||||
return WindowGroup {
|
||||
RootAnsicht()
|
||||
.environment(zustand)
|
||||
.task { await zustand.starte() }
|
||||
.preferredColorScheme(schema)
|
||||
// Einmal hier, damit jeder Knopf, Schalter und Link
|
||||
// die Markenfarbe traegt statt des System-Blaus.
|
||||
.tint(.marke)
|
||||
.onChange(of: phase) { _, neue in
|
||||
// `inactive` kommt, bevor iOS das Bild für den
|
||||
// App-Umschalter macht. Dort soll der Sperrbildschirm
|
||||
// stehen, nicht der Kontostand.
|
||||
switch neue {
|
||||
case .inactive, .background: zustand.gehtWeg()
|
||||
case .active: zustand.kommtZurueck()
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Welcher Bildschirm zu sehen ist, entscheidet allein `AppZustand.lage`.
|
||||
/// Jede Ansicht darunter darf davon ausgehen, dass es einen angemeldeten
|
||||
/// Client gibt.
|
||||
struct RootAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
|
||||
var body: some View {
|
||||
switch zustand.lage {
|
||||
case .startet:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .brauchtServer:
|
||||
ServerAnsicht()
|
||||
|
||||
case .brauchtAnmeldung:
|
||||
AnmeldeAnsicht()
|
||||
|
||||
case .brauchtZweitenFaktor:
|
||||
ZweiFaktorAnsicht()
|
||||
|
||||
case .brauchtPasswortwechsel:
|
||||
PasswortWechselAnsicht()
|
||||
|
||||
case .brauchtZweitenFaktorEinrichtung:
|
||||
ZweitenFaktorEinrichtenHinweis()
|
||||
|
||||
case .bereit:
|
||||
// Reihenfolge mit Absicht: Ohne PIN kommt niemand an die Zahlen,
|
||||
// und der Sperrbildschirm liegt vor allem anderen.
|
||||
if !zustand.hatPIN {
|
||||
PINEinrichtenAnsicht()
|
||||
} else if zustand.gesperrt {
|
||||
SperrAnsicht()
|
||||
} else {
|
||||
Hauptnavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
|
||||
/// erledigt `.sidebarAdaptable` von selbst.
|
||||
///
|
||||
/// Reihenfolge nach Häufigkeit, nicht nach Umfang: Das Dashboard beantwortet
|
||||
/// die Frage, wegen der man die App öffnet. Konten und Einstellungen stehen
|
||||
/// hinten, weil man sie selten braucht.
|
||||
struct Hauptnavigation: View {
|
||||
var body: some View {
|
||||
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") {
|
||||
NavigationStack { KontenListe() }
|
||||
}
|
||||
Tab(String(localized: "reiter.einstellungen"), systemImage: "gearshape") {
|
||||
NavigationStack { EinstellungenAnsicht() }
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
}
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
/// Die Farben aus der Datenbank sind Hex-Zeichenketten wie `#3f51b5`.
|
||||
/// Alles, was nicht passt, wird zum neutralen Grau – eine kaputte Farbe
|
||||
/// darf keine Ansicht leer lassen.
|
||||
init(hex: String) {
|
||||
var text = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.hasPrefix("#") { text.removeFirst() }
|
||||
// Das Backend erlaubt acht Stellen (#rrggbbaa); die Durchsicht nehmen
|
||||
// wir mit, der Rest wird ignoriert.
|
||||
guard text.count == 6 || text.count == 8,
|
||||
let zahl = UInt64(text, radix: 16) else {
|
||||
self = Color(red: 0.42, green: 0.45, blue: 0.50)
|
||||
return
|
||||
}
|
||||
let hatAlpha = text.count == 8
|
||||
let r = Double((zahl >> (hatAlpha ? 24 : 16)) & 0xFF) / 255
|
||||
let g = Double((zahl >> (hatAlpha ? 16 : 8)) & 0xFF) / 255
|
||||
let b = Double((zahl >> (hatAlpha ? 8 : 0)) & 0xFF) / 255
|
||||
let a = hatAlpha ? Double(zahl & 0xFF) / 255 : 1
|
||||
self = Color(.sRGB, red: r, green: g, blue: b, opacity: a)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// Die Markenfarbe: dasselbe Grün wie `--accent` in `web/src/styles.css`,
|
||||
/// damit App, Weboberfläche und Appicon eine Familie sind. In Dunkel eine
|
||||
/// Spur heller, sonst säuft das Grün auf dunklem Grund ab.
|
||||
static let marke = Color(uiColor: UIColor { zug in
|
||||
zug.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.373, green: 0.741, blue: 0.569, alpha: 1) // #5fbd91
|
||||
: UIColor(red: 0.247, green: 0.612, blue: 0.447, alpha: 1) // #3f9c72
|
||||
})
|
||||
}
|
||||
|
||||
/// Die Farbauswahl für Kategorien, Schlagwörter und Konten – eine Liste für
|
||||
/// alle drei Formulare, damit sie nicht auseinanderlaufen. Das Markengrün
|
||||
/// steht vorn und ist die Vorbelegung; der Rest bleibt bunt, sonst liessen
|
||||
/// sich die Kategorien in Diagrammen nicht auseinanderhalten.
|
||||
enum Farbwahl {
|
||||
static let standard = "#3f9c72"
|
||||
static let palette = [standard, "#3f51b5", "#b3541e", "#8e24aa",
|
||||
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
|
||||
}
|
||||
|
||||
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
|
||||
///
|
||||
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
|
||||
/// SVG-Verzeichnis (`web/src/components/Icon.jsx`, knapp 600 Zeilen). Nativ
|
||||
/// wäre das Unsinn: iOS bringt die Symbole mit, sie passen zur Systemschrift
|
||||
/// und können fett, farbig und in jeder Grösse. Nur die Namen muss jemand
|
||||
/// zuordnen – das ist diese Tabelle.
|
||||
///
|
||||
/// Die Liste deckt `ACCOUNT_ICONS` und `CATEGORY_ICONS` aus
|
||||
/// `web/src/components/IconPicker.jsx` ab. Unbekanntes wird zum Platzhalter,
|
||||
/// nie zu einer leeren Stelle.
|
||||
enum Symbole {
|
||||
static let platzhalter = "circle.dashed"
|
||||
|
||||
private static let tabelle: [String: String] = [
|
||||
// Konten
|
||||
"wallet": "wallet.bifold", "landmark": "building.columns",
|
||||
"piggy-bank": "banknote", "vault": "lock.square.stack",
|
||||
"credit-card": "creditcard", "trending-up": "chart.line.uptrend.xyaxis",
|
||||
"banknote": "banknote", "coins": "centsign.circle",
|
||||
// Wohnen
|
||||
"home": "house", "bed": "bed.double", "zap": "bolt",
|
||||
"droplet": "drop", "flame": "flame", "wifi": "wifi",
|
||||
"monitor": "display", "cloud": "cloud",
|
||||
// Alltag
|
||||
"shopping-cart": "cart", "shopping-bag": "bag", "store": "storefront",
|
||||
"utensils": "fork.knife", "coffee": "cup.and.saucer",
|
||||
"wine": "wineglass", "shirt": "tshirt", "scissors": "scissors",
|
||||
"sparkle": "sparkles",
|
||||
// Unterwegs
|
||||
"car": "car", "fuel": "fuelpump", "bus": "bus", "bike": "bicycle",
|
||||
"plane": "airplane", "ticket": "ticket",
|
||||
// Gesundheit
|
||||
"heart-pulse": "heart.text.square", "pill": "pills",
|
||||
"dumbbell": "figure.strengthtraining.traditional",
|
||||
// Freizeit
|
||||
"film": "film", "music": "music.note", "book-open": "book",
|
||||
"newspaper": "newspaper", "graduation-cap": "graduationcap",
|
||||
"paw-print": "pawprint",
|
||||
// Familie
|
||||
"sprout": "leaf", "cake": "birthday.cake", "gift": "gift",
|
||||
"baby": "figure.and.child.holdinghands", "heart": "heart",
|
||||
// Vorsorge
|
||||
"umbrella": "umbrella", "life-buoy": "lifepreserver",
|
||||
"shield": "shield", "scale": "scalemass",
|
||||
// Sonstiges
|
||||
"briefcase": "briefcase", "wrench": "wrench.adjustable",
|
||||
"hammer": "hammer", "phone": "phone", "refresh-ccw": "arrow.triangle.2.circlepath",
|
||||
"percent": "percent", "receipt": "receipt",
|
||||
"list": "list.bullet", "more-horizontal": "ellipsis",
|
||||
// In der Oberfläche benutzt, nicht in der Auswahl
|
||||
"tags": "tag", "inbox": "tray", "settings": "gearshape",
|
||||
"arrow-left-right": "arrow.left.arrow.right", "pie-chart": "chart.pie",
|
||||
"dashboard": "square.grid.2x2", "download": "square.and.arrow.down",
|
||||
"calendar": "calendar", "search": "magnifyingglass",
|
||||
"edit": "pencil", "trash": "trash", "plus": "plus", "check": "checkmark",
|
||||
"close": "xmark", "chevron-down": "chevron.down",
|
||||
]
|
||||
|
||||
static func sf(_ name: String?) -> String {
|
||||
guard let name, let treffer = tabelle[name] else { return platzhalter }
|
||||
return treffer
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein farbiges Symbolplättchen – das native Gegenstück zum `IconChip` im Web.
|
||||
struct Symbolplakette: View {
|
||||
let symbol: String?
|
||||
let farbe: String
|
||||
var groesse: CGFloat = 34
|
||||
|
||||
var body: some View {
|
||||
let farbwert = Color(hex: farbe)
|
||||
RoundedRectangle(cornerRadius: groesse * 0.28, style: .continuous)
|
||||
.fill(farbwert.opacity(0.16))
|
||||
.frame(width: groesse, height: groesse)
|
||||
.overlay {
|
||||
Image(systemName: Symbole.sf(symbol))
|
||||
.font(.system(size: groesse * 0.46, weight: .medium))
|
||||
.foregroundStyle(farbwert)
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
@@ -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,118 @@
|
||||
import Foundation
|
||||
|
||||
/// Geld, so wie das Backend es führt: ganze Cent plus Währungscode.
|
||||
///
|
||||
/// Nirgends Fliesskomma. Die API kennt ausschliesslich Cent-Ganzzahlen
|
||||
/// (einzige Ausnahme ist der Kurzbefehl-Endpunkt), und das bleibt hier so –
|
||||
/// ein `Double` hätte bei Summen über viele Buchungen Rundungsfehler.
|
||||
struct Geldbetrag: Hashable, Sendable {
|
||||
let cents: Int
|
||||
let waehrung: String
|
||||
|
||||
init(cents: Int, waehrung: String) {
|
||||
self.cents = cents
|
||||
self.waehrung = waehrung.uppercased()
|
||||
}
|
||||
|
||||
var dezimal: Decimal { Decimal(cents) / Decimal(100) }
|
||||
var istNull: Bool { cents == 0 }
|
||||
var istNegativ: Bool { cents < 0 }
|
||||
|
||||
/// Für die Anzeige, in der Sprache des Geräts.
|
||||
///
|
||||
/// `vorzeichen: .immer` schreibt auch bei positiven Beträgen ein Plus –
|
||||
/// das braucht die Buchungsliste, wo Einnahme und Ausgabe nebeneinander
|
||||
/// stehen und das Vorzeichen die einzige Unterscheidung ist.
|
||||
func formatiert(vorzeichen: Vorzeichenstil = .nurMinus) -> String {
|
||||
let wert = vorzeichen == .ohne ? abs(dezimal) : dezimal
|
||||
var text = wert.formatted(.currency(code: waehrung).locale(.autoupdatingCurrent))
|
||||
if vorzeichen == .immer, cents > 0 {
|
||||
text = "+" + text
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
enum Vorzeichenstil: Sendable {
|
||||
/// Minus bei negativen Beträgen, sonst nichts. Der Normalfall.
|
||||
case nurMinus
|
||||
/// Auch bei positiven ein Plus.
|
||||
case immer
|
||||
/// Immer ohne – wenn die Richtung schon woanders steht.
|
||||
case ohne
|
||||
}
|
||||
|
||||
/// Ohne Nachkommastellen, für enge Stellen wie Diagramm-Achsen.
|
||||
var gerundet: String {
|
||||
(dezimal as NSDecimalNumber).doubleValue.formatted(
|
||||
.currency(code: waehrung).precision(.fractionLength(0)))
|
||||
}
|
||||
|
||||
static func + (links: Geldbetrag, rechts: Geldbetrag) -> Geldbetrag {
|
||||
precondition(links.waehrung == rechts.waehrung,
|
||||
"Betraege verschiedener Waehrungen lassen sich nicht addieren")
|
||||
return Geldbetrag(cents: links.cents + rechts.cents, waehrung: links.waehrung)
|
||||
}
|
||||
|
||||
static prefix func - (betrag: Geldbetrag) -> Geldbetrag {
|
||||
Geldbetrag(cents: -betrag.cents, waehrung: betrag.waehrung)
|
||||
}
|
||||
}
|
||||
|
||||
extension Geldbetrag {
|
||||
/// Was der Nutzer tippt, in Cent.
|
||||
///
|
||||
/// Nimmt Komma und Punkt als Dezimaltrenner – auf einer deutschen Tastatur
|
||||
/// tippt man „12,50", auf der Zehnertastatur „12.50", und beides meint
|
||||
/// dasselbe. Tausendertrenner und Währungszeichen fliegen raus.
|
||||
static func ausEingabe(_ eingabe: String, waehrung: String) -> Geldbetrag? {
|
||||
var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
|
||||
let negativ = text.hasPrefix("-")
|
||||
text = text.filter { $0.isNumber || $0 == "," || $0 == "." }
|
||||
// Ohne eine einzige Ziffer ist das keine Zahl, sondern ein Vertipper.
|
||||
// Sonst käme aus "abc" ein Betrag von 0,00 heraus.
|
||||
guard text.contains(where: \.isNumber) else { return nil }
|
||||
|
||||
// Steht beides drin, ist das hintere der Dezimaltrenner und das
|
||||
// vordere der Tausendertrenner ("1.234,50" wie "1,234.50").
|
||||
let letztesKomma = text.lastIndex(of: ",")
|
||||
let letzterPunkt = text.lastIndex(of: ".")
|
||||
let trenner: Character?
|
||||
switch (letztesKomma, letzterPunkt) {
|
||||
case (nil, nil): trenner = nil
|
||||
case (_?, nil): trenner = ","
|
||||
case (nil, _?): trenner = "."
|
||||
case (let komma?, let punkt?): trenner = komma > punkt ? "," : "."
|
||||
}
|
||||
|
||||
var ganze = text
|
||||
var bruch = ""
|
||||
if let trenner, let stelle = text.lastIndex(of: trenner) {
|
||||
ganze = String(text[text.startIndex..<stelle])
|
||||
bruch = String(text[text.index(after: stelle)...])
|
||||
}
|
||||
ganze = ganze.filter(\.isNumber)
|
||||
bruch = bruch.filter(\.isNumber)
|
||||
|
||||
// Mehr als zwei Nachkommastellen war keine Dezimalangabe, sondern ein
|
||||
// Tausendertrenner ("1.234" sind 1234, nicht 1.23).
|
||||
if bruch.count > 2 {
|
||||
ganze += bruch
|
||||
bruch = ""
|
||||
}
|
||||
bruch = String((bruch + "00").prefix(2))
|
||||
|
||||
guard let vorne = Int(ganze.isEmpty ? "0" : ganze), let hinten = Int(bruch) else {
|
||||
return nil
|
||||
}
|
||||
let summe = vorne * 100 + hinten
|
||||
return Geldbetrag(cents: negativ ? -summe : summe, waehrung: waehrung)
|
||||
}
|
||||
|
||||
/// Zum Vorbelegen eines Eingabefelds: „12.50", ohne Währung und Trenner.
|
||||
var alsEingabe: String {
|
||||
let betrag = abs(cents)
|
||||
return "\(betrag / 100),\(String(format: "%02d", betrag % 100))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Der Hinweis über einer Liste, deren Daten aus dem Zwischenspeicher kommen.
|
||||
///
|
||||
/// Wichtiger als er aussieht: Ohne ihn zeigt die App im Flugmodus alte Zahlen,
|
||||
/// die aussehen wie frische. Mit Datum, weil „offline" allein nicht sagt, ob
|
||||
/// der Stand von vor fünf Minuten oder von vor drei Wochen ist.
|
||||
struct StandHinweis: View {
|
||||
let geholtAm: Date
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Text(String(format: String(localized: "stand.hinweis"),
|
||||
geholtAm.formatted(.relative(presentation: .named))))
|
||||
} icon: {
|
||||
Image(systemName: "wifi.slash")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.4))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wenn gar nichts da ist: Fehler zeigen und, wo es Sinn ergibt, einen
|
||||
/// Knopf zum Nochmalversuchen.
|
||||
struct FehlerAnsicht: View {
|
||||
let fehler: APIFehler
|
||||
var nochmal: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "fehler.titel"), systemImage: symbol)
|
||||
} description: {
|
||||
Text(fehler.errorDescription ?? String(localized: "fehler.unbekannt"))
|
||||
} actions: {
|
||||
if let nochmal, fehler.lohntWiederholung {
|
||||
Button(String(localized: "allgemein.nochmal"), action: nochmal)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch fehler {
|
||||
case .keineVerbindung: return "wifi.slash"
|
||||
case .nichtAngemeldet: return "person.crop.circle.badge.exclamationmark"
|
||||
case .nichtGefunden: return "questionmark.folder"
|
||||
default: return "exclamationmark.triangle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Liste, die noch nichts enthält – aber nicht wegen eines Fehlers.
|
||||
struct LeerAnsicht: View {
|
||||
let titel: String
|
||||
let erklaerung: String
|
||||
var symbol: String = "tray"
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label(titel, systemImage: symbol)
|
||||
} description: {
|
||||
Text(erklaerung)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt die drei Zustände für eine Liste zusammen, damit das nicht jede
|
||||
/// Ansicht neu baut.
|
||||
struct Inhalt<A: Sendable, Darstellung: View>: View {
|
||||
let zustand: Ladezustand<A>
|
||||
var nochmal: (() -> Void)?
|
||||
@ViewBuilder let darstellung: (A) -> Darstellung
|
||||
|
||||
var body: some View {
|
||||
switch zustand {
|
||||
case .laedt:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .da(let wert):
|
||||
darstellung(wert)
|
||||
case .ausSpeicher(let wert, let wann, _):
|
||||
VStack(spacing: 0) {
|
||||
StandHinweis(geholtAm: wann)
|
||||
darstellung(wert)
|
||||
}
|
||||
case .fehler(let fehler):
|
||||
FehlerAnsicht(fehler: fehler, nochmal: nochmal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */; };
|
||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4119B85EFF3FB38E84FAA913 /* Konto.swift */; };
|
||||
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */; };
|
||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
|
||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */; };
|
||||
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */; };
|
||||
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; };
|
||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
|
||||
2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */; };
|
||||
2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */; };
|
||||
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */; };
|
||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
|
||||
428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */; };
|
||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287D314176EBA5E888605F5F /* Geldbetrag.swift */; };
|
||||
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */; };
|
||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; };
|
||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
|
||||
5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */; };
|
||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */; };
|
||||
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */ = {isa = PBXBuildFile; fileRef = 3137E525D1ADE3DB2F8E45A8 /* antworten.json */; };
|
||||
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */; };
|
||||
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 */; };
|
||||
90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F14B825905BA99472E4B289 /* BankAnsicht.swift */; };
|
||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; };
|
||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; };
|
||||
A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = C841BA87849B2A461449BAF5 /* KategorieFormular.swift */; };
|
||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; };
|
||||
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */; };
|
||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
|
||||
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = D42C757A9D818A524C4CAC8A /* AppIcon.icon */; };
|
||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; };
|
||||
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 778040646D05B1DEEE3BF727 /* Formular.swift */; };
|
||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; };
|
||||
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */; };
|
||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; };
|
||||
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */; };
|
||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BDF076C7F062202810699EE9 /* Localizable.strings */; };
|
||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; };
|
||||
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; };
|
||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; };
|
||||
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
2D43432195E8A8F77E79DF49 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = E5DF7640265CD3906602698E /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 273DB28E6F6B8944B4879A06;
|
||||
remoteInfo = Certain;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
|
||||
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = "<group>"; };
|
||||
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Arbeitsliste.swift; sourceTree = "<group>"; };
|
||||
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetZeileBearbeiten.swift; sourceTree = "<group>"; };
|
||||
13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; };
|
||||
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; };
|
||||
1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1F14B825905BA99472E4B289 /* BankAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BankAnsicht.swift; sourceTree = "<group>"; };
|
||||
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrundlagenTests.swift; sourceTree = "<group>"; };
|
||||
287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = "<group>"; };
|
||||
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nebenraeume.swift; sourceTree = "<group>"; };
|
||||
3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; };
|
||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; };
|
||||
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SperrAnsicht.swift; sourceTree = "<group>"; };
|
||||
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = "<group>"; };
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
|
||||
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = "<group>"; };
|
||||
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = "<group>"; };
|
||||
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArbeitslisteAnsicht.swift; sourceTree = "<group>"; };
|
||||
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswortWechselAnsicht.swift; sourceTree = "<group>"; };
|
||||
5D3341CDA026CDF55D621A8A /* CertainTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CertainTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5D6CEAB46FB8955115A3064B /* Endpunkte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpunkte.swift; sourceTree = "<group>"; };
|
||||
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eingaben.swift; sourceTree = "<group>"; };
|
||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zeitangaben.swift; sourceTree = "<group>"; };
|
||||
776F26865B849D2318B6819B /* Zustandsansichten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zustandsansichten.swift; sourceTree = "<group>"; };
|
||||
777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorienAnsicht.swift; sourceTree = "<group>"; };
|
||||
778040646D05B1DEEE3BF727 /* Formular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formular.swift; sourceTree = "<group>"; };
|
||||
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = "<group>"; };
|
||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = "<group>"; };
|
||||
89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsAnsicht.swift; sourceTree = "<group>"; };
|
||||
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = "<group>"; };
|
||||
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboListe.swift; sourceTree = "<group>"; };
|
||||
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetAnsicht.swift; sourceTree = "<group>"; };
|
||||
A150CAB4967A479B506A997F /* BuchungsListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungsListe.swift; sourceTree = "<group>"; };
|
||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiFaktorAnsicht.swift; sourceTree = "<group>"; };
|
||||
A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuchungFormular.swift; sourceTree = "<group>"; };
|
||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAnsicht.swift; sourceTree = "<group>"; };
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kategorie.swift; sourceTree = "<group>"; };
|
||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dashboard.swift; sourceTree = "<group>"; };
|
||||
C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zwischenspeicher.swift; sourceTree = "<group>"; };
|
||||
C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIFehler.swift; sourceTree = "<group>"; };
|
||||
C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorieFormular.swift; sourceTree = "<group>"; };
|
||||
D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiterFaktorAnsicht.swift; sourceTree = "<group>"; };
|
||||
D42C757A9D818A524C4CAC8A /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = "<group>"; };
|
||||
DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = "<group>"; };
|
||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; };
|
||||
E51DE5BDB909910D586F819F /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = "<group>"; };
|
||||
E8CBA42A030509615199DD45 /* Abo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Abo.swift; sourceTree = "<group>"; };
|
||||
EFABF1A481445B1CD960C993 /* AboFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboFormular.swift; sourceTree = "<group>"; };
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grundmodelle.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0EDDD922C06B1465AD13C782 /* Texte */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BDF076C7F062202810699EE9 /* Localizable.strings */,
|
||||
);
|
||||
path = Texte;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
298E43BBA9248E5A9779DA98 /* Konten */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */,
|
||||
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */,
|
||||
);
|
||||
path = Konten;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
524FD85EDE7C4DE5ABE9A8E5 /* Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */,
|
||||
7DD1C597D594D0F965FF1547 /* Fixtures */,
|
||||
);
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
56CB7B1C18136BFD83ACADBD /* Einstellungen */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */,
|
||||
);
|
||||
path = Einstellungen;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5E720D91F0CF9DBDA975A959 /* Bausteine */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
451BA41D9B53FB6B511AF734 /* Darstellung.swift */,
|
||||
778040646D05B1DEEE3BF727 /* Formular.swift */,
|
||||
287D314176EBA5E888605F5F /* Geldbetrag.swift */,
|
||||
776F26865B849D2318B6819B /* Zustandsansichten.swift */,
|
||||
);
|
||||
path = Bausteine;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7DD1C597D594D0F965FF1547 /* Fixtures */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3137E525D1ADE3DB2F8E45A8 /* antworten.json */,
|
||||
);
|
||||
path = Fixtures;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E8CBA42A030509615199DD45 /* Abo.swift */,
|
||||
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */,
|
||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
||||
E51DE5BDB909910D586F819F /* Budget.swift */,
|
||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
||||
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */,
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
||||
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */,
|
||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
||||
);
|
||||
path = Modelle;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AE11D36E2DFAC34130E2868F /* Dashboard */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */,
|
||||
);
|
||||
path = Dashboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4505C6E527A0020B80AFF04 /* Anmeldung */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
|
||||
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
|
||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
|
||||
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */,
|
||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
|
||||
);
|
||||
path = Anmeldung;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BC82A6A323BDD2AD8325BEA2 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D42C757A9D818A524C4CAC8A /* AppIcon.icon */,
|
||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
|
||||
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
||||
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
||||
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */,
|
||||
5E720D91F0CF9DBDA975A959 /* Bausteine */,
|
||||
D99101183ED9DA14C8D22722 /* Buchungen */,
|
||||
C4B3E718A814E0236A12EB86 /* Budget */,
|
||||
AE11D36E2DFAC34130E2868F /* Dashboard */,
|
||||
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
||||
298E43BBA9248E5A9779DA98 /* Konten */,
|
||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
||||
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */,
|
||||
DB51F54CC077E85C286AD9DC /* Netzwerk */,
|
||||
524FD85EDE7C4DE5ABE9A8E5 /* Tests */,
|
||||
0EDDD922C06B1465AD13C782 /* Texte */,
|
||||
C36A0DD814B82B9DA47A4D58 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BEE6BC97D865C2F0C4EB80C4 /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */,
|
||||
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */,
|
||||
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C36A0DD814B82B9DA47A4D58 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
416FD5B020E78140037306C8 /* Certain.app */,
|
||||
5D3341CDA026CDF55D621A8A /* CertainTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13C14F1D9CEF39FF3E095239 /* Ablage.swift */,
|
||||
C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */,
|
||||
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */,
|
||||
5D6CEAB46FB8955115A3064B /* Endpunkte.swift */,
|
||||
DBDF1014AC23C916BE571DDC /* Lader.swift */,
|
||||
C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */,
|
||||
);
|
||||
path = Netzwerk;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1F14B825905BA99472E4B289 /* BankAnsicht.swift */,
|
||||
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */,
|
||||
777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */,
|
||||
89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */,
|
||||
D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */,
|
||||
);
|
||||
path = Nebenraeume;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */,
|
||||
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */,
|
||||
);
|
||||
path = Arbeitsliste;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
273DB28E6F6B8944B4879A06 /* Certain */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 5FB7AD5694029CE8047B30A6 /* Build configuration list for PBXNativeTarget "Certain" */;
|
||||
buildPhases = (
|
||||
AD804F932D01445AA1EBDDF6 /* Sources */,
|
||||
0C89482C2F6A53CC6DF65D35 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Certain;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = Certain;
|
||||
productReference = 416FD5B020E78140037306C8 /* Certain.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
30BF803561347EA12325BCAA /* CertainTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B7C4A363393DD132E27BF360 /* Build configuration list for PBXNativeTarget "CertainTests" */;
|
||||
buildPhases = (
|
||||
104B97AFE88042A9CF83B9C7 /* Sources */,
|
||||
5E084A3C9ED86572E29E01B7 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
183CFCE6D6F6769B7A0CFE43 /* PBXTargetDependency */,
|
||||
);
|
||||
name = CertainTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CertainTests;
|
||||
productReference = 5D3341CDA026CDF55D621A8A /* CertainTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
E5DF7640265CD3906602698E /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1430;
|
||||
TargetAttributes = {
|
||||
273DB28E6F6B8944B4879A06 = {
|
||||
DevelopmentTeam = PP34X97WS3;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = FE4C13F0F9703E456BEE9496 /* Build configuration list for PBXProject "Certain" */;
|
||||
developmentRegion = de;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
de,
|
||||
en,
|
||||
);
|
||||
mainGroup = BC82A6A323BDD2AD8325BEA2;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = C36A0DD814B82B9DA47A4D58 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
273DB28E6F6B8944B4879A06 /* Certain */,
|
||||
30BF803561347EA12325BCAA /* CertainTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
0C89482C2F6A53CC6DF65D35 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */,
|
||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5E084A3C9ED86572E29E01B7 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
104B97AFE88042A9CF83B9C7 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AD804F932D01445AA1EBDDF6 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */,
|
||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */,
|
||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */,
|
||||
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */,
|
||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
|
||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */,
|
||||
2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */,
|
||||
2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */,
|
||||
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */,
|
||||
90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */,
|
||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
|
||||
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */,
|
||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
|
||||
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */,
|
||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */,
|
||||
428AA2C2634A6F94D2B30158 /* BudgetZeileBearbeiten.swift in Sources */,
|
||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */,
|
||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */,
|
||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */,
|
||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */,
|
||||
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */,
|
||||
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */,
|
||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */,
|
||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */,
|
||||
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */,
|
||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
|
||||
A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */,
|
||||
5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */,
|
||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
||||
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */,
|
||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
||||
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */,
|
||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
|
||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
|
||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */,
|
||||
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */,
|
||||
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */,
|
||||
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */,
|
||||
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */,
|
||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */,
|
||||
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */,
|
||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
183CFCE6D6F6769B7A0CFE43 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 273DB28E6F6B8944B4879A06 /* Certain */;
|
||||
targetProxy = 2D43432195E8A8F77E79DF49 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
BDF076C7F062202810699EE9 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
C5F948B06FF2E65118C82636 /* de */,
|
||||
1E6FE0925FC71A03818FFF72 /* en */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
0C1990D56F8E2B65D3DB0A44 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Certain;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
507EC2B7E4B0269B65F8F2A2 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
6E14A372460452CBE33D2F62 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.CertainTests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Certain.app/Certain";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
78BBFF0B27B6614A8955859E /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
9611AED428CDAEAF407DCF13 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.CertainTests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Certain.app/Certain";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CADCD521080B321D3D652106 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Certain;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
5FB7AD5694029CE8047B30A6 /* Build configuration list for PBXNativeTarget "Certain" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CADCD521080B321D3D652106 /* Debug */,
|
||||
0C1990D56F8E2B65D3DB0A44 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
B7C4A363393DD132E27BF360 /* Build configuration list for PBXNativeTarget "CertainTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
6E14A372460452CBE33D2F62 /* Debug */,
|
||||
9611AED428CDAEAF407DCF13 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
FE4C13F0F9703E456BEE9496 /* Build configuration list for PBXProject "Certain" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
507EC2B7E4B0269B65F8F2A2 /* Debug */,
|
||||
78BBFF0B27B6614A8955859E /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = E5DF7640265CD3906602698E /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "273DB28E6F6B8944B4879A06"
|
||||
BuildableName = "Certain.app"
|
||||
BlueprintName = "Certain"
|
||||
ReferencedContainer = "container:Certain.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "273DB28E6F6B8944B4879A06"
|
||||
BuildableName = "Certain.app"
|
||||
BlueprintName = "Certain"
|
||||
ReferencedContainer = "container:Certain.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "30BF803561347EA12325BCAA"
|
||||
BuildableName = "CertainTests.xctest"
|
||||
BlueprintName = "CertainTests"
|
||||
ReferencedContainer = "container:Certain.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "273DB28E6F6B8944B4879A06"
|
||||
BuildableName = "Certain.app"
|
||||
BlueprintName = "Certain"
|
||||
ReferencedContainer = "container:Certain.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "273DB28E6F6B8944B4879A06"
|
||||
BuildableName = "Certain.app"
|
||||
BlueprintName = "Certain"
|
||||
ReferencedContainer = "container:Certain.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Einstellungen und die Nebenräume: Kategorien, Schlagwörter, Bank,
|
||||
/// zweiter Faktor.
|
||||
///
|
||||
/// Sie hängen hier und nicht als eigene Reiter: Man braucht sie selten, und
|
||||
/// eine Leiste mit zehn Reitern findet niemand mehr.
|
||||
struct EinstellungenAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var zeigeAbmeldefrage = false
|
||||
@State private var markierte: [MarkiertesWort] = []
|
||||
@State private var aenderePIN = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
if case .bereit(let benutzer) = zustand.lage {
|
||||
LabeledContent(String(localized: "einstellungen.angemeldetAls"),
|
||||
value: benutzer.anzeigename)
|
||||
}
|
||||
if let adresse = zustand.serverAdresse {
|
||||
LabeledContent(String(localized: "einstellungen.server"),
|
||||
value: adresse.host() ?? adresse.absoluteString)
|
||||
}
|
||||
NavigationLink {
|
||||
ZweiterFaktorAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield")
|
||||
}
|
||||
Button {
|
||||
aenderePIN = true
|
||||
} label: {
|
||||
Label(String(localized: "pin.aendern"), systemImage: "lock.rotation")
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "einstellungen.zugang"))
|
||||
} footer: {
|
||||
Text(String(localized: "einstellungen.markeErklaerung"))
|
||||
}
|
||||
|
||||
Section(String(localized: "nebenraeume.titel")) {
|
||||
NavigationLink {
|
||||
KategorienAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "nebenraeume.kategorien"), systemImage: "square.grid.2x2")
|
||||
}
|
||||
NavigationLink {
|
||||
TagsAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "nebenraeume.tags"), systemImage: "tag")
|
||||
}
|
||||
NavigationLink {
|
||||
BankAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "nebenraeume.bank"), systemImage: "building.columns")
|
||||
}
|
||||
}
|
||||
|
||||
if !markierte.isEmpty {
|
||||
Section {
|
||||
ForEach(markierte) { wort in
|
||||
MarkiertesWortZeile(wort: wort,
|
||||
behalten: { Task { await behalte(wort) } },
|
||||
entfernen: { Task { await entferne(wort) } })
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "kategorien.markiertTitel"))
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.markiertErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker(String(localized: "einstellungen.erscheinungsbild"),
|
||||
selection: bindung(\.erscheinungsbild, "theme")) {
|
||||
Text(String(localized: "einstellungen.system")).tag("system")
|
||||
Text(String(localized: "einstellungen.hell")).tag("light")
|
||||
Text(String(localized: "einstellungen.dunkel")).tag("dark")
|
||||
}
|
||||
Picker(String(localized: "einstellungen.hauptwaehrung"),
|
||||
selection: bindung(\.basiswaehrung, "base_currency")) {
|
||||
ForEach(zustand.einstellungen.aktiveWaehrungen, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
Picker(String(localized: "einstellungen.budgetStarttag"),
|
||||
selection: tagBindung) {
|
||||
ForEach(1...28, id: \.self) { tag in
|
||||
Text("\(tag).").tag(tag)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "einstellungen.ausDemServer"))
|
||||
} footer: {
|
||||
// Die Hauptwährung rechnet alle Auswertungen um. Das gilt
|
||||
// für jedes Gerät, nicht nur für dieses.
|
||||
Text(String(localized: "einstellungen.serverweitErklaerung"))
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||
zeigeAbmeldefrage = true
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "einstellungen.abmeldenErklaerung"))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $aenderePIN) {
|
||||
PINEinrichtenAnsicht(pflicht: false)
|
||||
}
|
||||
.navigationTitle(String(localized: "reiter.einstellungen"))
|
||||
.refreshable { await lade() }
|
||||
.task { await lade() }
|
||||
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
|
||||
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
|
||||
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||
Task { await zustand.meldeAb() }
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Kontenübersicht – die erste Fachansicht und zugleich die Probe, ob
|
||||
/// Netzwerk, Modelle, Zwischenspeicher und Geldformatierung zusammenpassen.
|
||||
struct KontenListe: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<[Konto]>?
|
||||
@State private var zeigeArchivierte = false
|
||||
@State private var formular: KontoFormular.Zweck?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { konten in
|
||||
liste(konten)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "konten.titel"))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Toggle(String(localized: "konten.archivierteZeigen"),
|
||||
isOn: $zeigeArchivierte)
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { formular = .neu } label: { Image(systemName: "plus") }
|
||||
.accessibilityLabel(String(localized: "konto.neu"))
|
||||
}
|
||||
}
|
||||
.sheet(item: $formular) { zweck in
|
||||
KontoFormular(zweck: zweck) { Task { await lader?.aktualisiere() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
let neuer = Lader<[Konto]>(schluessel: "accounts") {
|
||||
try await client.konten()
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func liste(_ alle: [Konto]) -> some View {
|
||||
let sichtbar = alle.filter { zeigeArchivierte || !$0.isArchived }
|
||||
|
||||
if sichtbar.isEmpty {
|
||||
LeerAnsicht(titel: String(localized: "konten.leerTitel"),
|
||||
erklaerung: String(localized: "konten.leerErklaerung"),
|
||||
symbol: "wallet.bifold")
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
ForEach(sichtbar) { konto in
|
||||
Button {
|
||||
formular = .aendern(konto)
|
||||
} label: {
|
||||
KontoZeile(konto: konto)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
} footer: {
|
||||
Gesamtsumme(konten: sichtbar,
|
||||
waehrung: zustand.einstellungen.basiswaehrung)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await lader?.aktualisiere() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct KontoZeile: View {
|
||||
let konto: Konto
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Symbolplakette(symbol: konto.icon, farbe: konto.color, groesse: 36)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(konto.name)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
if konto.isLocked {
|
||||
// „Gebundenes Geld": zählt nicht zum verfügbaren Betrag.
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(String(localized: "konten.gebunden"))
|
||||
}
|
||||
if konto.isArchived {
|
||||
Text(String(localized: "konten.archiviert"))
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
}
|
||||
Text(konto.type.name)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(konto.saldo.formatiert())
|
||||
.font(.body.monospacedDigit())
|
||||
.foregroundStyle(konto.balanceCents < 0 ? .red : .primary)
|
||||
if konto.weichtVonDerBankAb, let bank = konto.bankBalanceCents {
|
||||
// Nur melden, nicht korrigieren – welcher Wert stimmt,
|
||||
// entscheidet der Mensch.
|
||||
Label(Geldbetrag(cents: bank, waehrung: konto.currency).formatiert(),
|
||||
systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
.accessibilityLabel(String(localized: "konten.bankWeichtAb"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Summe unten. Gebundene und archivierte Konten bleiben aussen vor –
|
||||
/// genauso rechnet das Dashboard im Backend (`routers/dashboard.py`).
|
||||
private struct Gesamtsumme: View {
|
||||
let konten: [Konto]
|
||||
let waehrung: String
|
||||
|
||||
var body: some View {
|
||||
let verfuegbar = konten
|
||||
.filter { !$0.isLocked && !$0.isArchived }
|
||||
.reduce(0) { $0 + $1.balanceBaseCents }
|
||||
let gebunden = konten
|
||||
.filter { $0.isLocked && !$0.isArchived }
|
||||
.reduce(0) { $0 + $1.balanceBaseCents }
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(String(localized: "konten.verfuegbar"))
|
||||
Spacer()
|
||||
Text(Geldbetrag(cents: verfuegbar, waehrung: waehrung).formatiert())
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
if gebunden != 0 {
|
||||
HStack {
|
||||
Text(String(localized: "konten.gebundenSumme"))
|
||||
Spacer()
|
||||
Text(Geldbetrag(cents: gebunden, waehrung: waehrung).formatiert())
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
@@ -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,145 @@
|
||||
import Foundation
|
||||
|
||||
// Die Typen hier bilden die Antwortschemas aus `backend/app/schemas.py` nach.
|
||||
// Feldnamen stimmen eins zu eins überein – deshalb braucht keiner von ihnen
|
||||
// `CodingKeys`. Wer ein Feld umbenennt, bricht genau hier.
|
||||
|
||||
/// `GET /api/version` – die Prüfung, ob unter einer Adresse ein Certain läuft.
|
||||
struct Serverkennung: Codable, Sendable {
|
||||
let name: String
|
||||
let version: String
|
||||
let gitSha: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, version
|
||||
case gitSha = "git_sha"
|
||||
}
|
||||
|
||||
/// Der Name, den ein echter Server trägt (`backend/app/branding.py`).
|
||||
var istCertain: Bool { name.localizedCaseInsensitiveContains("certain") }
|
||||
}
|
||||
|
||||
/// `UserOut`
|
||||
struct Benutzer: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let username: String
|
||||
let role: Rolle
|
||||
let displayName: String?
|
||||
let isActive: Bool
|
||||
let mustChangePassword: Bool
|
||||
let mustChangePasswordReason: String?
|
||||
let totpEnabled: Bool
|
||||
let createdAt: Zeitpunkt
|
||||
let mfaSetupRequired: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, username, role
|
||||
case displayName = "display_name"
|
||||
case isActive = "is_active"
|
||||
case mustChangePassword = "must_change_password"
|
||||
case mustChangePasswordReason = "must_change_password_reason"
|
||||
case totpEnabled = "totp_enabled"
|
||||
case createdAt = "created_at"
|
||||
case mfaSetupRequired = "mfa_setup_required"
|
||||
}
|
||||
|
||||
enum Rolle: String, Codable, Sendable {
|
||||
case admin, user
|
||||
}
|
||||
|
||||
var anzeigename: String { displayName?.isEmpty == false ? displayName! : username }
|
||||
}
|
||||
|
||||
/// `LoginOut` – `status` ist `"ok"` oder `"2fa_required"`.
|
||||
struct AnmeldeAntwort: Codable, Sendable {
|
||||
let status: String
|
||||
let user: Benutzer?
|
||||
|
||||
var brauchtZweitenFaktor: Bool { status == "2fa_required" }
|
||||
}
|
||||
|
||||
/// `TwoFactorVerify`
|
||||
struct ZweiterFaktorEingabe: Codable, Sendable {
|
||||
let code: String
|
||||
let recovery: Bool
|
||||
}
|
||||
|
||||
/// `PasswordChange`
|
||||
struct PasswortWechsel: Codable, Sendable {
|
||||
let currentPassword: String
|
||||
let newPassword: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case currentPassword = "current_password"
|
||||
case newPassword = "new_password"
|
||||
}
|
||||
}
|
||||
|
||||
/// `ApiTokenCreate`
|
||||
struct MarkeAnlegen: Codable, Sendable {
|
||||
let name: String
|
||||
let scope: String
|
||||
let defaultAccountId: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, scope
|
||||
case defaultAccountId = "default_account_id"
|
||||
}
|
||||
|
||||
/// Die Marke, mit der sich die App anmeldet.
|
||||
static func fuerApp(name: String) -> MarkeAnlegen {
|
||||
MarkeAnlegen(name: name, scope: "app", defaultAccountId: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ApiTokenOut` beziehungsweise `ApiTokenCreated`.
|
||||
/// `token` trägt den Klartext – und zwar genau einmal, beim Anlegen.
|
||||
struct Marke: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let scope: String
|
||||
let defaultAccountId: Int?
|
||||
let lastUsedAt: Zeitpunkt?
|
||||
let createdAt: Zeitpunkt?
|
||||
let token: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, scope, token
|
||||
case defaultAccountId = "default_account_id"
|
||||
case lastUsedAt = "last_used_at"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Eintrag aus `GET /api/settings`. Werte sind immer Zeichenketten.
|
||||
struct Einstellung: Codable, Sendable, Hashable {
|
||||
let key: String
|
||||
let value: String
|
||||
}
|
||||
|
||||
/// Die Einstellungen als benutzbares Ganzes.
|
||||
struct Einstellungen: Sendable, Hashable {
|
||||
var basiswaehrung: String = "EUR"
|
||||
var budgetStarttag: Int = 1
|
||||
var sprache: String = "de"
|
||||
var erscheinungsbild: String = "system"
|
||||
var datumsformat: String = "de"
|
||||
var aktiveWaehrungen: [String] = ["EUR", "CHF", "USD"]
|
||||
|
||||
init() {}
|
||||
|
||||
init(_ eintraege: [Einstellung]) {
|
||||
var nach: [String: String] = [:]
|
||||
for eintrag in eintraege { nach[eintrag.key] = eintrag.value }
|
||||
basiswaehrung = nach["base_currency"] ?? basiswaehrung
|
||||
budgetStarttag = min(31, max(1, Int(nach["budget_start_day"] ?? "") ?? 1))
|
||||
sprache = nach["locale"] ?? sprache
|
||||
erscheinungsbild = nach["theme"] ?? erscheinungsbild
|
||||
datumsformat = nach["date_format"] ?? datumsformat
|
||||
if let roh = nach["active_currencies"] {
|
||||
aktiveWaehrungen = roh.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).uppercased() }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,87 @@
|
||||
import Foundation
|
||||
|
||||
/// `AccountOut` aus `backend/app/schemas.py`.
|
||||
struct Konto: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let type: Kontoart
|
||||
let currency: String
|
||||
let icon: String
|
||||
let color: String
|
||||
let openingBalanceCents: Int
|
||||
let openingDate: Tagesdatum?
|
||||
let creditLimitCents: Int?
|
||||
let isLocked: Bool
|
||||
let isArchived: Bool
|
||||
let position: Int
|
||||
let savingsCategoryId: Int?
|
||||
let identifiers: [Kontokennung]
|
||||
/// Saldo in der Währung des Kontos.
|
||||
let balanceCents: Int
|
||||
/// Derselbe Saldo in der Hauptwährung – vom Server gerechnet.
|
||||
let balanceBaseCents: Int
|
||||
/// Was die Bank selbst meldet. Nur bei angebundenen Konten gesetzt.
|
||||
let bankBalanceCents: Int?
|
||||
let bankBalanceAt: Zeitpunkt?
|
||||
/// Nur ein gebuchter Banksaldo lässt sich mit unserer Rechnung
|
||||
/// vergleichen; ein verfügbarer enthält Vorgemerktes.
|
||||
let bankBalanceVergleichbar: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, type, currency, icon, color, position, identifiers
|
||||
case openingBalanceCents = "opening_balance_cents"
|
||||
case openingDate = "opening_date"
|
||||
case creditLimitCents = "credit_limit_cents"
|
||||
case isLocked = "is_locked"
|
||||
case isArchived = "is_archived"
|
||||
case savingsCategoryId = "savings_category_id"
|
||||
case balanceCents = "balance_cents"
|
||||
case balanceBaseCents = "balance_base_cents"
|
||||
case bankBalanceCents = "bank_balance_cents"
|
||||
case bankBalanceAt = "bank_balance_at"
|
||||
case bankBalanceVergleichbar = "bank_balance_vergleichbar"
|
||||
}
|
||||
|
||||
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
|
||||
|
||||
/// Ob der gemeldete Banksaldo von unserer Rechnung abweicht – nur dann ist
|
||||
/// ein Hinweis angebracht.
|
||||
var weichtVonDerBankAb: Bool {
|
||||
guard bankBalanceVergleichbar, let bank = bankBalanceCents else { return false }
|
||||
return bank != balanceCents
|
||||
}
|
||||
}
|
||||
|
||||
/// `AccountType`
|
||||
enum Kontoart: String, Codable, Sendable, CaseIterable {
|
||||
case girokonto, sparkonto, bargeld, kreditkarte, depot, sonstiges
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .girokonto: return String(localized: "kontoart.girokonto")
|
||||
case .sparkonto: return String(localized: "kontoart.sparkonto")
|
||||
case .bargeld: return String(localized: "kontoart.bargeld")
|
||||
case .kreditkarte: return String(localized: "kontoart.kreditkarte")
|
||||
case .depot: return String(localized: "kontoart.depot")
|
||||
case .sonstiges: return String(localized: "kontoart.sonstiges")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Übertrag auf diese Konten zählt im Budget als Sparen, nicht als
|
||||
/// neutrale Umbuchung (`SAVINGS_ACCOUNT_TYPES` im Backend).
|
||||
var giltAlsSparen: Bool { self == .sparkonto || self == .depot }
|
||||
}
|
||||
|
||||
/// `AccountIdentifierOut`
|
||||
struct Kontokennung: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let kind: Art
|
||||
let value: String
|
||||
|
||||
enum Art: String, Codable, Sendable {
|
||||
case iban
|
||||
case accountNo = "account_no"
|
||||
case cardNo = "card_no"
|
||||
case text
|
||||
}
|
||||
}
|
||||
@@ -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,127 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Kalendertag ohne Uhrzeit, so wie das Backend ihn schickt: `"2026-07-14"`.
|
||||
///
|
||||
/// Warum ein eigener Typ und nicht `Date` mit einer Decoder-Strategie: Das
|
||||
/// Backend mischt zwei Formate. Buchungsdaten sind reine Tage, `created_at`
|
||||
/// und Konsorten sind volle Zeitstempel mit Zone. Eine einzige
|
||||
/// `dateDecodingStrategy` müsste beides raten und würde mal das eine, mal das
|
||||
/// andere zerlegen. Zwei saubere Typen sind hier weniger Arbeit als ein
|
||||
/// cleverer Decoder.
|
||||
struct Tagesdatum: Codable, Hashable, Comparable, Sendable, CustomStringConvertible {
|
||||
/// Mitternacht dieses Tages, in der Zeitzone des Geräts.
|
||||
let datum: Date
|
||||
/// Die Form, in der es kam und wieder hinausgeht.
|
||||
let text: String
|
||||
|
||||
private static let leser: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.calendar = Calendar(identifier: .gregorian)
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = .current
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
init?(_ text: String) {
|
||||
guard let geparst = Tagesdatum.leser.date(from: text) else { return nil }
|
||||
self.datum = geparst
|
||||
self.text = text
|
||||
}
|
||||
|
||||
init(_ datum: Date) {
|
||||
self.datum = datum
|
||||
self.text = Tagesdatum.leser.string(from: datum)
|
||||
}
|
||||
|
||||
static var heute: Tagesdatum { Tagesdatum(Date()) }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let roh = try decoder.singleValueContainer().decode(String.self)
|
||||
guard let fertig = Tagesdatum(roh) else {
|
||||
throw DecodingError.dataCorrupted(.init(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Kein Tagesdatum: \(roh)"))
|
||||
}
|
||||
self = fertig
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var behaelter = encoder.singleValueContainer()
|
||||
try behaelter.encode(text)
|
||||
}
|
||||
|
||||
static func < (links: Tagesdatum, rechts: Tagesdatum) -> Bool {
|
||||
links.datum < rechts.datum
|
||||
}
|
||||
|
||||
var description: String { text }
|
||||
|
||||
/// Für die Anzeige, in der Sprache des Geräts.
|
||||
func formatiert(_ stil: Date.FormatStyle.DateStyle = .abbreviated) -> String {
|
||||
datum.formatted(.dateTime.day().month(.defaultDigits).year())
|
||||
.replacingOccurrences(of: "\u{202F}", with: " ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein voller Zeitstempel, wie ihn `created_at`, `last_used_at` und
|
||||
/// `bank_balance_at` tragen.
|
||||
///
|
||||
/// Das Backend schickt ISO 8601, mal mit Sekundenbruchteilen, mal ohne, mal
|
||||
/// mit `Z`, mal mit Offset. Deshalb zwei Leser hintereinander statt einem.
|
||||
struct Zeitpunkt: Codable, Hashable, Comparable, Sendable {
|
||||
let datum: Date
|
||||
|
||||
private static let mitBruchteilen: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let ohneBruchteile: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Letzter Ausweg: Python schreibt ohne Zone, wenn keine gesetzt ist.
|
||||
private static let ohneZone: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.calendar = Calendar(identifier: .gregorian)
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(identifier: "UTC")
|
||||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
return f
|
||||
}()
|
||||
|
||||
init(_ datum: Date) { self.datum = datum }
|
||||
|
||||
init?(_ text: String) {
|
||||
let ohneMikro = text.replacingOccurrences(
|
||||
of: #"(\.\d{3})\d+"#, with: "$1", options: .regularExpression)
|
||||
if let d = Zeitpunkt.mitBruchteilen.date(from: ohneMikro) { self.datum = d; return }
|
||||
if let d = Zeitpunkt.ohneBruchteile.date(from: text) { self.datum = d; return }
|
||||
let gekuerzt = String(text.prefix(19))
|
||||
if let d = Zeitpunkt.ohneZone.date(from: gekuerzt) { self.datum = d; return }
|
||||
return nil
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let roh = try decoder.singleValueContainer().decode(String.self)
|
||||
guard let fertig = Zeitpunkt(roh) else {
|
||||
throw DecodingError.dataCorrupted(.init(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Kein Zeitpunkt: \(roh)"))
|
||||
}
|
||||
self = fertig
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var behaelter = encoder.singleValueContainer()
|
||||
try behaelter.encode(Zeitpunkt.mitBruchteilen.string(from: datum))
|
||||
}
|
||||
|
||||
static func < (links: Zeitpunkt, rechts: Zeitpunkt) -> Bool {
|
||||
links.datum < rechts.datum
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
|
||||
/// Was beim Reden mit dem Certain-Backend schiefgehen kann.
|
||||
///
|
||||
/// Die Fälle sind nach dem geschnitten, was die App damit *tun* muss – nicht
|
||||
/// nach HTTP-Status. `pflichtOffen` führt auf einen anderen Bildschirm,
|
||||
/// `konflikt` ist eine Rückfrage, die sich mit „trotzdem" wiederholen lässt,
|
||||
/// alles übrige ist eine Meldung.
|
||||
enum APIFehler: Error, LocalizedError, Sendable, Equatable {
|
||||
/// Netz weg, Server nicht erreichbar, Zeitüberschreitung.
|
||||
case keineVerbindung(String)
|
||||
/// Unter der Adresse antwortet etwas, aber kein Certain.
|
||||
case keinCertainServer
|
||||
/// 401 – Marke ungültig oder widerrufen, Sitzung abgelaufen.
|
||||
case nichtAngemeldet
|
||||
/// 403 mit maschinenlesbarem Schlüssel: erst muss etwas erledigt werden.
|
||||
case pflichtOffen(Pflicht)
|
||||
/// 403 aus einem anderen Grund.
|
||||
case verboten(String)
|
||||
/// 404 – gibt es nicht, oder gehört jemand anderem.
|
||||
case nichtGefunden
|
||||
/// 409 – meistens eine Rückfrage, die sich mit `trotzdem = true`
|
||||
/// wiederholen lässt. Der Text ist für den Nutzer geschrieben.
|
||||
case konflikt(String)
|
||||
/// 400 oder 422.
|
||||
case ungueltig(String)
|
||||
/// Alles übrige, was der Server sagt.
|
||||
case server(status: Int, text: String)
|
||||
/// Die Antwort passt nicht zu dem, was wir erwarten.
|
||||
case antwortUnlesbar(String)
|
||||
|
||||
/// Was der Benutzer erledigen muss, bevor die API ihn durchlässt.
|
||||
/// Die Werte sind die Schlüssel aus `backend/app/deps.py`.
|
||||
enum Pflicht: String, Sendable {
|
||||
case passwortWechseln = "password_change_required"
|
||||
case zweiterFaktor = "mfa_setup_required"
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .keineVerbindung(let grund):
|
||||
return String(format: String(localized: "fehler.keineVerbindung"), grund)
|
||||
case .keinCertainServer:
|
||||
return String(localized: "fehler.keinCertainServer")
|
||||
case .nichtAngemeldet:
|
||||
return String(localized: "fehler.nichtAngemeldet")
|
||||
case .pflichtOffen(.passwortWechseln):
|
||||
return String(localized: "fehler.passwortWechseln")
|
||||
case .pflichtOffen(.zweiterFaktor):
|
||||
return String(localized: "fehler.zweiterFaktor")
|
||||
case .verboten(let text), .konflikt(let text), .ungueltig(let text):
|
||||
return text
|
||||
case .nichtGefunden:
|
||||
return String(localized: "fehler.nichtGefunden")
|
||||
case .server(let status, let text):
|
||||
return text.isEmpty
|
||||
? String(format: String(localized: "fehler.server"), status)
|
||||
: text
|
||||
case .antwortUnlesbar(let grund):
|
||||
return String(format: String(localized: "fehler.antwortUnlesbar"), grund)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob ein erneuter Versuch überhaupt Sinn ergibt.
|
||||
var lohntWiederholung: Bool {
|
||||
switch self {
|
||||
case .keineVerbindung, .server:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Fehlerkörper des Backends.
|
||||
///
|
||||
/// `detail` ist dort mal eine Zeichenkette (`{"detail": "Nicht gefunden"}`),
|
||||
/// mal eine Liste von Pydantic-Meldungen (bei 422). Beides muss hier durch,
|
||||
/// sonst verliert die App bei jedem Eingabefehler die Meldung.
|
||||
struct Fehlerantwort: Decodable, Sendable {
|
||||
let detail: String
|
||||
|
||||
private enum Schluessel: String, CodingKey { case detail }
|
||||
|
||||
private struct Einzelfehler: Decodable {
|
||||
let msg: String?
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let behaelter = try decoder.container(keyedBy: Schluessel.self)
|
||||
if let text = try? behaelter.decode(String.self, forKey: .detail) {
|
||||
detail = text
|
||||
} else if let liste = try? behaelter.decode([Einzelfehler].self, forKey: .detail) {
|
||||
detail = liste.compactMap(\.msg).joined(separator: ", ")
|
||||
} else {
|
||||
detail = ""
|
||||
}
|
||||
}
|
||||
|
||||
/// Versucht, aus Antwortdaten eine Meldung zu ziehen. Schlägt das fehl,
|
||||
/// kommt eine leere Zeichenkette – nie ein Fehler, denn wir sind hier
|
||||
/// bereits im Fehlerfall.
|
||||
static func ausDaten(_ daten: Data) -> String {
|
||||
(try? JSONDecoder().decode(Fehlerantwort.self, from: daten))?.detail ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Wo die App ihre Einstellungen und ihr Geheimnis hinlegt.
|
||||
///
|
||||
/// Zwei getrennte Orte, mit Absicht: Die Server-Adresse ist keine geheime
|
||||
/// Angabe und liegt in den Voreinstellungen. Die Gerätemarke ist ein
|
||||
/// Vollzugang und gehört in den Schlüsselbund – dort überlebt sie kein
|
||||
/// unverschlüsseltes Backup und keine Gerätesicherung ohne Passcode.
|
||||
enum Ablage {
|
||||
/// Setze das hier auf deine App-Gruppe, sobald es Erweiterungen gibt
|
||||
/// (`group.<deine-bundle-id>`). Solange `nil`, benutzt die App ihren
|
||||
/// eigenen Bereich – das genügt, bis Teilen-Menü und Kurzbefehle dazukommen.
|
||||
static let gruppe: String? = nil
|
||||
|
||||
/// Dieselbe Gruppe für den Schlüsselbund. Erst nötig, wenn eine
|
||||
/// Erweiterung an dieselbe Marke muss.
|
||||
static let schluesselbundGruppe: String? = nil
|
||||
|
||||
private static var voreinstellungen: UserDefaults {
|
||||
if let gruppe, let geteilt = UserDefaults(suiteName: gruppe) { return geteilt }
|
||||
return .standard
|
||||
}
|
||||
|
||||
private enum Schluessel {
|
||||
static let serverAdresse = "certain.serverAdresse"
|
||||
static let marke = "certain.marke"
|
||||
static let benutzername = "certain.benutzername"
|
||||
static let pin = "certain.pin"
|
||||
}
|
||||
|
||||
// MARK: - Server-Adresse
|
||||
|
||||
static var serverAdresse: URL? {
|
||||
get {
|
||||
guard let text = voreinstellungen.string(forKey: Schluessel.serverAdresse) else {
|
||||
return nil
|
||||
}
|
||||
return URL(string: text)
|
||||
}
|
||||
set {
|
||||
if let neue = newValue {
|
||||
voreinstellungen.set(neue.absoluteString, forKey: Schluessel.serverAdresse)
|
||||
} else {
|
||||
voreinstellungen.removeObject(forKey: Schluessel.serverAdresse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nur zur Anzeige („angemeldet als …"), nicht zur Prüfung.
|
||||
static var benutzername: String? {
|
||||
get { voreinstellungen.string(forKey: Schluessel.benutzername) }
|
||||
set {
|
||||
if let neuer = newValue {
|
||||
voreinstellungen.set(neuer, forKey: Schluessel.benutzername)
|
||||
} else {
|
||||
voreinstellungen.removeObject(forKey: Schluessel.benutzername)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gerätemarke im Schlüsselbund
|
||||
|
||||
static var marke: String? {
|
||||
get { schluesselbundLesen(Schluessel.marke) }
|
||||
set {
|
||||
if let neue = newValue {
|
||||
schluesselbundSchreiben(Schluessel.marke, neue)
|
||||
} else {
|
||||
schluesselbundLoeschen(Schluessel.marke)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bildschirmsperre
|
||||
|
||||
/// Die PIN selbst wird nie abgelegt, nur ein gesalzener SHA-256 davon.
|
||||
/// Das Salz liegt daneben, beides im Schlüsselbund als `salz:hash` in Hex.
|
||||
///
|
||||
/// Die PIN verdeckt die Oberfläche, sie verschlüsselt nicht die
|
||||
/// Gerätemarke – wer das Telefon entsperrt hat und es auslesen kann,
|
||||
/// käme an die Marke. Gegen Mitlesen über die Schulter und gegen ein
|
||||
/// kurz liegengelassenes Telefon hilft sie, gegen Forensik nicht.
|
||||
static var hatPIN: Bool { schluesselbundLesen(Schluessel.pin) != nil }
|
||||
|
||||
static func setzePIN(_ pin: String) {
|
||||
var salz = Data(count: 16)
|
||||
_ = salz.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
schluesselbundSchreiben(Schluessel.pin, "\(hex(salz)):\(hex(verhashe(pin, salz: salz)))")
|
||||
}
|
||||
|
||||
static func stimmtPIN(_ pin: String) -> Bool {
|
||||
guard let abgelegt = schluesselbundLesen(Schluessel.pin) else { return false }
|
||||
let teile = abgelegt.split(separator: ":", maxSplits: 1)
|
||||
guard teile.count == 2, let salz = daten(hex: String(teile[0])) else { return false }
|
||||
return hex(verhashe(pin, salz: salz)) == String(teile[1])
|
||||
}
|
||||
|
||||
static func loeschePIN() { schluesselbundLoeschen(Schluessel.pin) }
|
||||
|
||||
private static func verhashe(_ pin: String, salz: Data) -> Data {
|
||||
Data(SHA256.hash(data: salz + Data(pin.utf8)))
|
||||
}
|
||||
|
||||
private static func hex(_ daten: Data) -> String {
|
||||
daten.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func daten(hex text: String) -> Data? {
|
||||
guard text.count % 2 == 0 else { return nil }
|
||||
var roh = Data(); var rest = Substring(text)
|
||||
while !rest.isEmpty {
|
||||
guard let wert = UInt8(rest.prefix(2), radix: 16) else { return nil }
|
||||
roh.append(wert); rest = rest.dropFirst(2)
|
||||
}
|
||||
return roh
|
||||
}
|
||||
|
||||
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
|
||||
static func raeumeAuf() {
|
||||
marke = nil
|
||||
loeschePIN()
|
||||
benutzername = nil
|
||||
serverAdresse = nil
|
||||
Zwischenspeicher.leere()
|
||||
}
|
||||
|
||||
// MARK: - Schlüsselbund, schmucklos
|
||||
|
||||
private static func grundlage(_ konto: String) -> [String: Any] {
|
||||
var abfrage: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "com.certain.app",
|
||||
kSecAttrAccount as String: konto,
|
||||
]
|
||||
if let schluesselbundGruppe {
|
||||
abfrage[kSecAttrAccessGroup as String] = schluesselbundGruppe
|
||||
}
|
||||
return abfrage
|
||||
}
|
||||
|
||||
private static func schluesselbundLesen(_ konto: String) -> String? {
|
||||
var abfrage = grundlage(konto)
|
||||
abfrage[kSecReturnData as String] = true
|
||||
abfrage[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var ergebnis: CFTypeRef?
|
||||
let status = SecItemCopyMatching(abfrage as CFDictionary, &ergebnis)
|
||||
guard status == errSecSuccess, let daten = ergebnis as? Data else { return nil }
|
||||
return String(data: daten, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func schluesselbundSchreiben(_ konto: String, _ wert: String) {
|
||||
let abfrage = grundlage(konto)
|
||||
let inhalt: [String: Any] = [
|
||||
kSecValueData as String: Data(wert.utf8),
|
||||
// Nicht in ein Backup und nicht auf ein anderes Gerät: Die Marke
|
||||
// gehoert genau diesem Telefon.
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
let status = SecItemUpdate(abfrage as CFDictionary, inhalt as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var neu = abfrage
|
||||
neu.merge(inhalt) { _, zweites in zweites }
|
||||
SecItemAdd(neu as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func schluesselbundLoeschen(_ konto: String) {
|
||||
SecItemDelete(grundlage(konto) as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import Foundation
|
||||
|
||||
/// Der einzige Weg der App zum Backend.
|
||||
///
|
||||
/// Ein Actor, weil Adresse und Marke sich zur Laufzeit ändern (an- und
|
||||
/// abmelden) und von überall gelesen werden.
|
||||
///
|
||||
/// Zwei Anmeldewege, und zwar nacheinander, nicht nebeneinander:
|
||||
/// Beim Anmelden setzt das Backend ein Sitzungs-Cookie, das `URLSession`
|
||||
/// von selbst mitführt. Noch in dieser Sitzung holt sich die App eine
|
||||
/// Gerätemarke und benutzt danach nur noch den Header `X-Certain-Token`.
|
||||
/// Das Cookie wird dann weggeworfen – siehe `vergissCookies()`.
|
||||
actor CertainClient {
|
||||
private(set) var basis: URL
|
||||
private(set) var marke: String?
|
||||
|
||||
private let sitzung: URLSession
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
init(basis: URL, marke: String? = nil) {
|
||||
self.basis = basis
|
||||
self.marke = marke
|
||||
|
||||
let einstellungen = URLSessionConfiguration.ephemeral
|
||||
einstellungen.httpCookieStorage = HTTPCookieStorage.shared
|
||||
einstellungen.httpShouldSetCookies = true
|
||||
einstellungen.httpCookieAcceptPolicy = .always
|
||||
// Alle /api-Antworten tragen `Cache-Control: no-store`; den lokalen
|
||||
// Zwischenspeicher führen wir selbst (siehe `Zwischenspeicher`), weil
|
||||
// wir ihn auch offline und mit Datumsangabe brauchen.
|
||||
einstellungen.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
einstellungen.timeoutIntervalForRequest = 30
|
||||
einstellungen.timeoutIntervalForResource = 120
|
||||
einstellungen.waitsForConnectivity = false
|
||||
sitzung = URLSession(configuration: einstellungen)
|
||||
}
|
||||
|
||||
// MARK: - Zustand
|
||||
|
||||
func setzeBasis(_ neue: URL) {
|
||||
basis = neue
|
||||
}
|
||||
|
||||
func setzeMarke(_ neue: String?) {
|
||||
marke = neue
|
||||
}
|
||||
|
||||
/// Nach dem Holen der Marke: Das Cookie wird nicht mehr gebraucht und soll
|
||||
/// nicht länger auf dem Gerät liegen als nötig.
|
||||
func vergissCookies() {
|
||||
guard let speicher = sitzung.configuration.httpCookieStorage else { return }
|
||||
for keks in speicher.cookies(for: basis) ?? [] {
|
||||
speicher.deleteCookie(keks)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aufrufe
|
||||
|
||||
enum Methode: String, Sendable {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case patch = "PATCH"
|
||||
case put = "PUT"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
|
||||
/// Lesen.
|
||||
func hole<A: Decodable>(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> A {
|
||||
let daten = try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil)
|
||||
return try entschluessele(daten)
|
||||
}
|
||||
|
||||
/// Schreiben ohne Nutzdaten (etwa `POST /uebertraege/suchen`).
|
||||
@discardableResult
|
||||
func schicke<A: Decodable>(_ methode: Methode, _ pfad: String,
|
||||
abfrage: [URLQueryItem] = []) async throws -> A {
|
||||
let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil)
|
||||
return try entschluessele(daten)
|
||||
}
|
||||
|
||||
/// Schreiben mit JSON-Körper.
|
||||
@discardableResult
|
||||
func schicke<A: Decodable, K: Encodable>(_ methode: Methode, _ pfad: String,
|
||||
koerper: K,
|
||||
abfrage: [URLQueryItem] = []) async throws -> A {
|
||||
let roh = try encoder.encode(koerper)
|
||||
let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh)
|
||||
return try entschluessele(daten)
|
||||
}
|
||||
|
||||
/// Für 204-Antworten: Es kommt nichts zurück, und das ist richtig so.
|
||||
func schickeStumm(_ methode: Methode, _ pfad: String,
|
||||
abfrage: [URLQueryItem] = []) async throws {
|
||||
_ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil)
|
||||
}
|
||||
|
||||
func schickeStumm<K: Encodable>(_ methode: Methode, _ pfad: String,
|
||||
koerper: K,
|
||||
abfrage: [URLQueryItem] = []) async throws {
|
||||
let roh = try encoder.encode(koerper)
|
||||
_ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh)
|
||||
}
|
||||
|
||||
/// Die Anmeldung geht als Formular, nicht als JSON – das Backend nutzt
|
||||
/// `OAuth2PasswordRequestForm` (`backend/app/routers/auth.py`).
|
||||
func schickeFormular<A: Decodable>(_ pfad: String,
|
||||
felder: [String: String]) async throws -> A {
|
||||
var teile = URLComponents()
|
||||
teile.queryItems = felder.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
// `+` muss als Zeichen durch, sonst wird daraus beim Server ein Leerzeichen.
|
||||
let text = (teile.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B")
|
||||
let daten = try await fuehreAus(
|
||||
.post, pfad, abfrage: [], koerper: Data(text.utf8),
|
||||
inhaltstyp: "application/x-www-form-urlencoded")
|
||||
return try entschluessele(daten)
|
||||
}
|
||||
|
||||
/// Datei hochladen (Logos, Kontoauszüge, Belege).
|
||||
func schickeDatei<A: Decodable>(_ pfad: String, feldname: String = "file",
|
||||
dateiname: String, inhalt: Data,
|
||||
typ: String = "application/octet-stream",
|
||||
weitereFelder: [String: String] = [:],
|
||||
abfrage: [URLQueryItem] = []) async throws -> A {
|
||||
let grenze = "Grenze-\(UUID().uuidString)"
|
||||
var koerper = Data()
|
||||
func anhaengen(_ text: String) { koerper.append(Data(text.utf8)) }
|
||||
|
||||
for (schluessel, wert) in weitereFelder {
|
||||
anhaengen("--\(grenze)\r\n")
|
||||
anhaengen("Content-Disposition: form-data; name=\"\(schluessel)\"\r\n\r\n")
|
||||
anhaengen("\(wert)\r\n")
|
||||
}
|
||||
anhaengen("--\(grenze)\r\n")
|
||||
anhaengen("Content-Disposition: form-data; name=\"\(feldname)\"; filename=\"\(dateiname)\"\r\n")
|
||||
anhaengen("Content-Type: \(typ)\r\n\r\n")
|
||||
koerper.append(inhalt)
|
||||
anhaengen("\r\n--\(grenze)--\r\n")
|
||||
|
||||
let daten = try await fuehreAus(
|
||||
.post, pfad, abfrage: abfrage, koerper: koerper,
|
||||
inhaltstyp: "multipart/form-data; boundary=\(grenze)")
|
||||
return try entschluessele(daten)
|
||||
}
|
||||
|
||||
/// Rohe Bytes holen – für Bilder (Abo-Logos) und Dateien.
|
||||
func holeBytes(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> Data {
|
||||
try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil)
|
||||
}
|
||||
|
||||
// MARK: - Innenleben
|
||||
|
||||
private func baueAdresse(_ pfad: String, _ abfrage: [URLQueryItem]) throws -> URL {
|
||||
let voll = basis.appendingPathComponent("api").appendingPathComponent(
|
||||
pfad.hasPrefix("/") ? String(pfad.dropFirst()) : pfad)
|
||||
guard var teile = URLComponents(url: voll, resolvingAgainstBaseURL: false) else {
|
||||
throw APIFehler.antwortUnlesbar(pfad)
|
||||
}
|
||||
// Leere Werte gar nicht erst mitschicken – das Backend liest "" sonst
|
||||
// als gesetzten Filter.
|
||||
let gefiltert = abfrage.filter { ($0.value ?? "").isEmpty == false }
|
||||
teile.queryItems = gefiltert.isEmpty ? nil : gefiltert
|
||||
guard let fertig = teile.url else { throw APIFehler.antwortUnlesbar(pfad) }
|
||||
return fertig
|
||||
}
|
||||
|
||||
private func fuehreAus(_ methode: Methode, _ pfad: String,
|
||||
abfrage: [URLQueryItem], koerper: Data?,
|
||||
inhaltstyp: String = "application/json") async throws -> Data {
|
||||
var anfrage = URLRequest(url: try baueAdresse(pfad, abfrage))
|
||||
anfrage.httpMethod = methode.rawValue
|
||||
anfrage.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let marke {
|
||||
anfrage.setValue(marke, forHTTPHeaderField: "X-Certain-Token")
|
||||
}
|
||||
if let koerper {
|
||||
anfrage.httpBody = koerper
|
||||
anfrage.setValue(inhaltstyp, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
let daten: Data
|
||||
let antwort: URLResponse
|
||||
do {
|
||||
(daten, antwort) = try await sitzung.data(for: anfrage)
|
||||
} catch let fehler as URLError {
|
||||
throw APIFehler.keineVerbindung(fehler.localizedDescription)
|
||||
} catch {
|
||||
throw APIFehler.keineVerbindung(error.localizedDescription)
|
||||
}
|
||||
|
||||
guard let http = antwort as? HTTPURLResponse else {
|
||||
throw APIFehler.antwortUnlesbar("keine HTTP-Antwort")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw deute(status: http.statusCode, daten: daten)
|
||||
}
|
||||
return daten
|
||||
}
|
||||
|
||||
private func deute(status: Int, daten: Data) -> APIFehler {
|
||||
let text = Fehlerantwort.ausDaten(daten)
|
||||
switch status {
|
||||
case 401:
|
||||
return .nichtAngemeldet
|
||||
case 403:
|
||||
if let pflicht = APIFehler.Pflicht(rawValue: text) {
|
||||
return .pflichtOffen(pflicht)
|
||||
}
|
||||
return .verboten(text)
|
||||
case 404:
|
||||
return .nichtGefunden
|
||||
case 409:
|
||||
return .konflikt(text)
|
||||
case 400, 422:
|
||||
return .ungueltig(text)
|
||||
default:
|
||||
return .server(status: status, text: text)
|
||||
}
|
||||
}
|
||||
|
||||
private func entschluessele<A: Decodable>(_ daten: Data) throws -> A {
|
||||
// 204 und andere leere Antworten: Wer hier `Leer` erwartet, bekommt es.
|
||||
if daten.isEmpty, let leer = Leer() as? A { return leer }
|
||||
do {
|
||||
return try decoder.decode(A.self, from: daten)
|
||||
} catch {
|
||||
throw APIFehler.antwortUnlesbar(String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Platzhalter für Antworten ohne Inhalt.
|
||||
struct Leer: Codable, Sendable {
|
||||
init() {}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import Foundation
|
||||
|
||||
// Alle Pfade an einer Stelle. An der Aufrufstelle steht nie eine Zeichenkette –
|
||||
// wer einen Endpunkt umbenennt, bricht genau hier und nicht in zwölf Ansichten.
|
||||
//
|
||||
// Stufe 1: Server prüfen, anmelden, Marke holen, Konten lesen.
|
||||
|
||||
extension CertainClient {
|
||||
|
||||
// MARK: - Server
|
||||
|
||||
/// Prüft, ob unter der Adresse überhaupt ein Certain läuft.
|
||||
///
|
||||
/// Wichtig: Das Backend liefert für jeden unbekannten Pfad ausserhalb von
|
||||
/// `/api` die Weboberfläche mit Status 200 aus. `/api/version` kann das
|
||||
/// nicht – nur ein echter Server antwortet hier mit dem erwarteten JSON.
|
||||
func serverkennung() async throws -> Serverkennung {
|
||||
let kennung: Serverkennung
|
||||
do {
|
||||
kennung = try await hole("version")
|
||||
} catch APIFehler.antwortUnlesbar {
|
||||
throw APIFehler.keinCertainServer
|
||||
}
|
||||
guard kennung.istCertain else { throw APIFehler.keinCertainServer }
|
||||
return kennung
|
||||
}
|
||||
|
||||
// MARK: - Anmeldung
|
||||
|
||||
/// Meldet an und bekommt ein Sitzungs-Cookie. Steht der zweite Faktor
|
||||
/// aus, ist `status == "2fa_required"` und das Cookie gilt nur zehn
|
||||
/// Minuten für genau diesen Schritt.
|
||||
func melde(benutzer: String, passwort: String) async throws -> AnmeldeAntwort {
|
||||
try await schickeFormular("auth/login",
|
||||
felder: ["username": benutzer, "password": passwort])
|
||||
}
|
||||
|
||||
func pruefeZweitenFaktor(code: String, wiederherstellung: Bool = false) async throws -> AnmeldeAntwort {
|
||||
try await schicke(.post, "auth/2fa/verify",
|
||||
koerper: ZweiterFaktorEingabe(code: code, recovery: wiederherstellung))
|
||||
}
|
||||
|
||||
func ichSelbst() async throws -> Benutzer {
|
||||
try await hole("auth/me")
|
||||
}
|
||||
|
||||
/// Wechselt das Passwort. Beendet dabei alle anderen Sitzungen des Kontos
|
||||
/// und setzt für den Aufrufer ein frisches Cookie.
|
||||
@discardableResult
|
||||
func wechslePasswort(alt: String, neu: String) async throws -> Benutzer {
|
||||
try await schicke(.post, "auth/change-password",
|
||||
koerper: PasswortWechsel(currentPassword: alt, newPassword: neu))
|
||||
}
|
||||
|
||||
func meldeAb() async throws {
|
||||
try await schickeStumm(.post, "auth/logout")
|
||||
}
|
||||
|
||||
// MARK: - Gerätemarken
|
||||
|
||||
/// Holt die Marke, mit der die App von da an arbeitet. Geht nur aus einer
|
||||
/// echten Anmeldung heraus – eine Marke darf keine weitere ausstellen.
|
||||
func legeAppMarkeAn(name: String) async throws -> Marke {
|
||||
try await schicke(.post, "inbox/tokens", koerper: MarkeAnlegen.fuerApp(name: name))
|
||||
}
|
||||
|
||||
func marken() async throws -> [Marke] {
|
||||
try await hole("inbox/tokens")
|
||||
}
|
||||
|
||||
func widerrufeMarke(id: Int) async throws {
|
||||
try await schickeStumm(.delete, "inbox/tokens/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Einstellungen
|
||||
|
||||
func einstellungen() async throws -> Einstellungen {
|
||||
let roh: [Einstellung] = try await hole("settings")
|
||||
return Einstellungen(roh)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setzeEinstellung(_ schluessel: String, _ wert: String) async throws -> Einstellung {
|
||||
// Query statt Körper – so will es das Backend (`routers/settings.py`).
|
||||
try await schicke(.put, "settings/\(schluessel)",
|
||||
abfrage: [URLQueryItem(name: "value", value: wert)])
|
||||
}
|
||||
|
||||
// MARK: - Konten
|
||||
|
||||
func konten() async throws -> [Konto] {
|
||||
try await hole("accounts")
|
||||
}
|
||||
|
||||
func konto(_ id: Int) async throws -> Konto {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Holt eine Liste, legt sie in den Zwischenspeicher und weiss, was bei einem
|
||||
/// Fehlschlag noch da ist.
|
||||
///
|
||||
/// Jede Listenansicht braucht dasselbe: laden, Fehler zeigen, zum Aktualisieren
|
||||
/// herunterziehen, offline den letzten Stand behalten. Einmal hier, statt in
|
||||
/// jeder Ansicht neu.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Lader<A: Codable & Sendable> {
|
||||
private(set) var zustand: Ladezustand<A> = .laedt
|
||||
|
||||
private let schluessel: String
|
||||
private let holen: () async throws -> A
|
||||
|
||||
/// - Parameter schluessel: Name im Zwischenspeicher. Der Endpunktpfad ist
|
||||
/// eine gute Wahl – er ist eindeutig und beim Suchen lesbar.
|
||||
init(schluessel: String, holen: @escaping () async throws -> A) {
|
||||
self.schluessel = schluessel
|
||||
self.holen = holen
|
||||
}
|
||||
|
||||
/// Lädt. Beim ersten Mal wird ein vorhandener Stand sofort angezeigt,
|
||||
/// damit die Liste nicht leer aufblitzt, während der Abruf läuft.
|
||||
func lade(zeigeGemerktesSofort: Bool = true) async {
|
||||
if zeigeGemerktesSofort, zustand.inhalt == nil,
|
||||
let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) {
|
||||
zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm,
|
||||
fehler: .keineVerbindung(""))
|
||||
} else if zustand.inhalt == nil {
|
||||
zustand = .laedt
|
||||
}
|
||||
|
||||
do {
|
||||
let frisch = try await holen()
|
||||
Zwischenspeicher.lege(frisch, unter: schluessel)
|
||||
zustand = .da(frisch)
|
||||
} catch let fehler as APIFehler {
|
||||
if let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) {
|
||||
zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm,
|
||||
fehler: fehler)
|
||||
} else {
|
||||
zustand = .fehler(fehler)
|
||||
}
|
||||
} catch {
|
||||
zustand = .fehler(.keineVerbindung(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fürs Herunterziehen: kein Zwischenzustand, die alte Liste bleibt
|
||||
/// stehen, bis die neue da ist.
|
||||
func aktualisiere() async {
|
||||
await lade(zeigeGemerktesSofort: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
/// Die letzte geglückte Antwort je Abruf, auf der Platte.
|
||||
///
|
||||
/// Wozu: In der U-Bahn und im Ausland soll die App nicht mit einer
|
||||
/// Fehlermeldung dastehen, sondern den letzten Stand zeigen – mit Datum, damit
|
||||
/// niemand alte Zahlen für aktuelle hält. Geschrieben wird offline nicht.
|
||||
///
|
||||
/// Bewusst schlicht: eine JSON-Datei je Schlüssel, kein Verfallsdatum, kein
|
||||
/// Aufräumen nach Grösse. Die Antworten sind klein (die grösste, die
|
||||
/// Buchungsliste, liegt im einstelligen Megabyte-Bereich), und ein Abmelden
|
||||
/// räumt ohnehin alles weg.
|
||||
enum Zwischenspeicher {
|
||||
/// Was aus dem Speicher kam, und wann es geholt wurde.
|
||||
struct Eintrag<A>: Sendable where A: Sendable {
|
||||
let wert: A
|
||||
let geholtAm: Date
|
||||
}
|
||||
|
||||
private static let ordner: URL? = {
|
||||
guard let basis = try? FileManager.default.url(
|
||||
for: .cachesDirectory, in: .userDomainMask,
|
||||
appropriateFor: nil, create: true) else { return nil }
|
||||
let eigener = basis.appendingPathComponent("CertainAntworten", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: eigener, withIntermediateDirectories: true)
|
||||
return eigener
|
||||
}()
|
||||
|
||||
private static func datei(_ schluessel: String) -> URL? {
|
||||
// Der Schluessel ist ein Pfad mit Abfrage; daraus muss ein gueltiger
|
||||
// Dateiname werden.
|
||||
let sauber = schluessel.unicodeScalars.map { zeichen -> Character in
|
||||
CharacterSet.alphanumerics.contains(zeichen) ? Character(zeichen) : "_"
|
||||
}
|
||||
return ordner?.appendingPathComponent(String(sauber) + ".json")
|
||||
}
|
||||
|
||||
static func lege<A: Encodable>(_ wert: A, unter schluessel: String) {
|
||||
guard let ziel = datei(schluessel),
|
||||
let daten = try? JSONEncoder().encode(wert) else { return }
|
||||
try? daten.write(to: ziel, options: .atomic)
|
||||
}
|
||||
|
||||
static func hole<A: Decodable & Sendable>(_ typ: A.Type, unter schluessel: String) -> Eintrag<A>? {
|
||||
guard let quelle = datei(schluessel),
|
||||
let daten = try? Data(contentsOf: quelle),
|
||||
let wert = try? JSONDecoder().decode(A.self, from: daten) else { return nil }
|
||||
let wann = (try? quelle.resourceValues(forKeys: [.contentModificationDateKey]))?
|
||||
.contentModificationDate ?? Date()
|
||||
return Eintrag(wert: wert, geholtAm: wann)
|
||||
}
|
||||
|
||||
static func leere() {
|
||||
guard let ordner else { return }
|
||||
try? FileManager.default.removeItem(at: ordner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was eine Ansicht gerade anzeigen soll.
|
||||
///
|
||||
/// Der vierte Fall ist der Grund für diesen Typ: Daten da, aber vom Band –
|
||||
/// die Ansicht muss das sagen können, ohne dass jede Ansicht ihre eigene
|
||||
/// Zustandsverwaltung erfindet.
|
||||
enum Ladezustand<A: Sendable>: Sendable {
|
||||
case laedt
|
||||
case da(A)
|
||||
/// Der Abruf schlug fehl, aber wir haben noch einen Stand von vorher.
|
||||
case ausSpeicher(A, geholtAm: Date, fehler: APIFehler)
|
||||
case fehler(APIFehler)
|
||||
|
||||
var inhalt: A? {
|
||||
switch self {
|
||||
case .da(let wert): return wert
|
||||
case .ausSpeicher(let wert, _, _): return wert
|
||||
case .laedt, .fehler: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var laeuft: Bool {
|
||||
if case .laedt = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
# Certain für iOS
|
||||
|
||||
Native App zum Certain-Backend.
|
||||
|
||||
> **Wer hier weiterarbeitet, liest zuerst [UEBERGABE.md](UEBERGABE.md).** Dort
|
||||
> steht, was entschieden ist, was fertig ist, was als Nächstes dran ist und
|
||||
> welche Eigenheiten das Backend hat. Dieses README erklärt nur das Projekt.
|
||||
|
||||
## Bauen und starten
|
||||
|
||||
Einfach `Certain.xcodeproj` öffnen — es liegt mit im Repository.
|
||||
|
||||
Die Wahrheit ist aber `project.yml`: eine XcodeGen-Spezifikation, aus der das
|
||||
Projekt entsteht. **Wer eine Swift-Datei anlegt, läuft danach einmal
|
||||
`xcodegen generate`**, sonst kennt Xcode sie nicht. Ein neuer *Ordner* will
|
||||
ausserdem eine Zeile unter `sources:`.
|
||||
|
||||
```
|
||||
brew install xcodegen # einmalig
|
||||
cd ios && xcodegen generate
|
||||
xcodebuild -project Certain.xcodeproj -scheme Certain \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
|
||||
xcodebuild -project Certain.xcodeproj -scheme Certain \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' test
|
||||
```
|
||||
|
||||
Festgelegt sind dort: Bündelkennung `com.scarriffle.certain`, Team
|
||||
`PP34X97WS3`, iPhone und iPad, Grundsprache Deutsch, **iOS 26** als
|
||||
Mindestversion, Sprachfassung Swift 5. Wer mit einem anderen Apple-Konto baut,
|
||||
trägt seine Team-ID in `project.yml` ein.
|
||||
|
||||
**Nur https.** App Transport Security steht auf der Voreinstellung, ein Server
|
||||
unter `http://…` wird also abgewiesen — auch `http://localhost` beim
|
||||
Entwickeln. Das ist Absicht: über die Verbindung geht die Gerätemarke, ein
|
||||
Vollzugang. Wer eine Ausnahme braucht, trägt sie bewusst in `project.yml` ein.
|
||||
|
||||
## Was wohin gehört
|
||||
|
||||
| Ordner | Inhalt |
|
||||
|---|---|
|
||||
| `App/` | Einstiegspunkt, Navigation, globaler Zustand |
|
||||
| `Netzwerk/` | Client, Endpunkte, Fehler, Offline-Ablage |
|
||||
| `Modelle/` | Codable-Typen, eins zu eins zu den Backend-Schemas |
|
||||
| `Bausteine/` | Wiederverwendetes: Geld, Farben, Symbole, Zustandsansichten, Formularfelder |
|
||||
| `Anmeldung/` | Server-Adresse, Anmeldung, zweiter Faktor, Passwortwechsel |
|
||||
| `Dashboard/` | Die Übersicht: vier feste Karten |
|
||||
| `Budget/` | Soll gegen Ist, Monat für Monat |
|
||||
| `Buchungen/` | Buchungsliste mit Suche, Formular zum Anlegen und Ändern |
|
||||
| `Abos/` | Verträge und Abos |
|
||||
| `Arbeitsliste/` | Eingang, Übertragsvorschläge, Dubletten |
|
||||
| `Nebenraeume/` | Kategorien, Schlagwörter, Bank, zweiter Faktor |
|
||||
| `Konten/` | Kontenliste und Konto-Formular |
|
||||
| `Texte/` | Übersetzungen, Deutsch und Englisch zeilengleich |
|
||||
| `Tests/` | Logiktests ohne Netz, plus die Modellprüfung gegen `Fixtures/antworten.json` |
|
||||
|
||||
## Wie die Anmeldung funktioniert
|
||||
|
||||
Das Backend kennt für die Weboberfläche nur ein Sitzungs-Cookie. Für die App
|
||||
wurde eine **Gerätemarke der Reichweite `app`** ergänzt
|
||||
(`backend/app/models.py`, `TokenScope`). Der Ablauf:
|
||||
|
||||
1. Nutzer gibt die Server-Adresse ein. Die App prüft mit `GET /api/version`, ob
|
||||
dort überhaupt ein Certain läuft.
|
||||
2. Nutzer meldet sich mit Benutzername und Passwort an — über das normale
|
||||
Cookie, inklusive zweitem Faktor und Pflicht-Passwortwechsel, falls fällig.
|
||||
3. Noch in derselben Sitzung legt die App über `POST /api/inbox/tokens` mit
|
||||
`scope: "app"` eine Marke an und legt deren Klartext im **Schlüsselbund** ab.
|
||||
4. Danach läuft **alles** über den Header `X-Certain-Token`. Das Cookie wird
|
||||
verworfen.
|
||||
|
||||
Damit gibt es kein Passwort auf dem Gerät und keinen Ablauf nach 30 Tagen. Die
|
||||
Marke lässt sich am Rechner unter Einstellungen → Gerätemarken einzeln
|
||||
widerrufen; danach sperrt sie sofort.
|
||||
|
||||
Eine App-Marke kann **keine weitere Marke ausstellen** — das geht nur mit einer
|
||||
echten Anmeldung. Sonst hätte ein Dieb sich Ersatzschlüssel ausgestellt und das
|
||||
Widerrufen wäre wirkungslos.
|
||||
|
||||
## Später: Erweiterungen
|
||||
|
||||
Teilen-Menü und Kurzbefehle kommen in Stufe 6. Beide brauchen Zugriff auf
|
||||
dieselbe Marke wie die App. Dafür ist im Projekt nötig:
|
||||
|
||||
- eine **App-Gruppe** (`group.<deine-bundle-id>`) für die Server-Adresse,
|
||||
- eine **geteilte Schlüsselbund-Gruppe** für die Marke selbst.
|
||||
|
||||
`Netzwerk/Ablage.swift` liest beides bereits aus Konstanten, die du an einer
|
||||
Stelle setzt (`Ablage.gruppe`). Solange keine Erweiterung existiert, funktioniert
|
||||
alles auch ohne App-Gruppe.
|
||||
|
||||
## Was die App bewusst nicht kann
|
||||
|
||||
- **Bankkonten verbinden.** Die Bank leitet auf eine feste Web-Adresse zurück,
|
||||
die bei Enable Banking registriert ist. Abrufen und Zuordnen geht nativ, das
|
||||
Herstellen der Verbindung bleibt im Browser.
|
||||
- **Postfach einrichten** und **Sicherung/Wiederherstellung.** Gehört an den
|
||||
Rechner.
|
||||
- **Benutzerverwaltung.** Administration für Mehrbenutzerbetrieb.
|
||||
|
||||
## Was geprüft ist — und was nicht
|
||||
|
||||
**Geprüft:** Jedes Feld der Modelle wurde gegen echte Antworten eines
|
||||
laufenden Backends verglichen (`/api/version`, `/api/auth/me`, `/api/accounts`,
|
||||
`/api/settings`, `/api/inbox/tokens`). Alle Namen stimmen überein. Dabei kam
|
||||
heraus, dass `created_at` ohne Zeitzone und mit Mikrosekunden kommt
|
||||
(`2026-10-20T14:18:15.780705`) — dafür hat `Zeitpunkt` einen dritten Leser,
|
||||
und `GrundlagenTests` deckt den Fall ab.
|
||||
|
||||
**Ebenfalls geprüft:** Dass es übersetzt. Ein einziger Fehler in 2600 blind
|
||||
geschriebenen Zeilen (`[:]` statt `[]` in `CertainClient.schickeDatei`), danach
|
||||
keine Warnung im eigenen Code, 28 Tests grün, Start im Simulator bis zur
|
||||
Serverabfrage.
|
||||
|
||||
**Nicht geprüft:** Der Weg gegen ein echtes Backend — anmelden, zweiter Faktor,
|
||||
Marke anlegen, Konten laden.
|
||||
|
||||
### Wo es beim ersten Bauen am ehesten klemmte
|
||||
|
||||
Nichts davon trat ein; die Liste bleibt als Landkarte der heiklen Stellen.
|
||||
|
||||
1. **`@testable import Certain`** in `Tests/GrundlagenTests.swift` — muss auf
|
||||
den Namen deines App-Ziels zeigen. Heisst es anders, anpassen.
|
||||
2. **`Lader` ist eine generische `@Observable`-Klasse.** Das geht, ist aber
|
||||
die ungewöhnlichste Konstruktion hier. Falls der Übersetzer murrt: Die
|
||||
Klasse wird bisher nur an einer Stelle benutzt (`KontenListe`).
|
||||
3. **Nebenläufigkeit.** `AppZustand` ist `@MainActor`, `CertainClient` ein
|
||||
Actor. Unter strikter Nebenläufigkeitsprüfung kann es an den Übergängen
|
||||
Warnungen setzen.
|
||||
4. **`Tab` und `.tabViewStyle(.sidebarAdaptable)`** in `RootAnsicht` setzen
|
||||
iOS 18 voraus. Bei iOS 26 als Mindestversion kein Problem.
|
||||
5. **Die Tests nutzen Swift Testing** (`import Testing`, `@Test`, `#expect`),
|
||||
nicht XCTest. Xcode 16 und neuer kann beides im selben Ziel.
|
||||
|
||||
Schick mir die Fehler, dann räumen wir sie weg.
|
||||
|
||||
## Stand
|
||||
|
||||
Stufe 1: Gerüst, Anmeldung samt zweitem Faktor und Passwortwechsel,
|
||||
Kontenliste. 21 Swift-Dateien, rund 2600 Zeilen. Siehe den Plan im Repository
|
||||
für die weiteren Stufen.
|
||||
|
||||
Noch nicht in der App, obwohl in Stufe 1 erreichbar: Einen zweiten Faktor
|
||||
**einrichten** geht nur im Browser. Wer ein Konto hat, für das die Richtlinie
|
||||
MFA verlangt, sieht deshalb einen Hinweis mit Link statt eines Einrichtungs-
|
||||
Assistenten. Das kommt mit den Einstellungen in Stufe 5.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,820 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Certain
|
||||
|
||||
// Reine Logik, kein Netz. Das ist alles, was sich ohne laufenden Server
|
||||
// prüfen lässt – und zugleich das, was beim blinden Schreiben am ehesten
|
||||
// still danebengeht.
|
||||
//
|
||||
// Hinweis: `@testable import Certain` muss auf den Namen deines App-Ziels
|
||||
// zeigen. Heisst es anders, hier anpassen.
|
||||
|
||||
// MARK: - Geldbeträge
|
||||
|
||||
@Suite("Geldbetrag")
|
||||
struct GeldbetragTests {
|
||||
|
||||
@Test("Komma und Punkt meinen dasselbe")
|
||||
func trennerSindAustauschbar() {
|
||||
#expect(Geldbetrag.ausEingabe("12,50", waehrung: "CHF")?.cents == 1250)
|
||||
#expect(Geldbetrag.ausEingabe("12.50", waehrung: "CHF")?.cents == 1250)
|
||||
#expect(Geldbetrag.ausEingabe("12", waehrung: "CHF")?.cents == 1200)
|
||||
#expect(Geldbetrag.ausEingabe("0,05", waehrung: "CHF")?.cents == 5)
|
||||
}
|
||||
|
||||
@Test("Bei beiden Zeichen ist das hintere der Dezimaltrenner")
|
||||
func tausenderUndDezimal() {
|
||||
#expect(Geldbetrag.ausEingabe("1.234,50", waehrung: "EUR")?.cents == 123450)
|
||||
#expect(Geldbetrag.ausEingabe("1,234.50", waehrung: "EUR")?.cents == 123450)
|
||||
}
|
||||
|
||||
@Test("Drei Stellen hinter dem Trenner sind Tausender, keine Rappen")
|
||||
func dreiStellenSindKeinBruch() {
|
||||
// "1.234" meint tausendzweihundertvierunddreissig, nicht 1,23.
|
||||
#expect(Geldbetrag.ausEingabe("1.234", waehrung: "EUR")?.cents == 123400)
|
||||
}
|
||||
|
||||
@Test("Vorzeichen bleibt erhalten")
|
||||
func negativ() {
|
||||
#expect(Geldbetrag.ausEingabe("-5", waehrung: "EUR")?.cents == -500)
|
||||
#expect(Geldbetrag.ausEingabe("-12,34", waehrung: "EUR")?.cents == -1234)
|
||||
}
|
||||
|
||||
@Test("Unsinn ergibt keinen Betrag")
|
||||
func unsinn() {
|
||||
#expect(Geldbetrag.ausEingabe("", waehrung: "EUR") == nil)
|
||||
#expect(Geldbetrag.ausEingabe(" ", waehrung: "EUR") == nil)
|
||||
// Der wichtige Fall: Ohne diese Prüfung käme hier 0,00 heraus und die
|
||||
// App buchte stillschweigend einen Nullbetrag.
|
||||
#expect(Geldbetrag.ausEingabe("abc", waehrung: "EUR") == nil)
|
||||
#expect(Geldbetrag.ausEingabe(",", waehrung: "EUR") == nil)
|
||||
}
|
||||
|
||||
@Test("Hin und zurück durchs Eingabefeld")
|
||||
func rundlauf() {
|
||||
let betrag = Geldbetrag(cents: 123450, waehrung: "CHF")
|
||||
#expect(betrag.alsEingabe == "1234,50")
|
||||
#expect(Geldbetrag.ausEingabe(betrag.alsEingabe, waehrung: "CHF")?.cents == 123450)
|
||||
|
||||
let krumm = Geldbetrag(cents: 5, waehrung: "CHF")
|
||||
#expect(krumm.alsEingabe == "0,05")
|
||||
#expect(Geldbetrag.ausEingabe(krumm.alsEingabe, waehrung: "CHF")?.cents == 5)
|
||||
}
|
||||
|
||||
@Test("Die Formatierung nennt Betrag und Währung")
|
||||
func formatierung() {
|
||||
let text = Geldbetrag(cents: 1250, waehrung: "CHF").formatiert()
|
||||
// Kein Vergleich auf die genaue Zeichenkette: Trennzeichen und Stellung
|
||||
// des Währungszeichens hängen an der Sprache des Geräts.
|
||||
#expect(text.contains("12"))
|
||||
#expect(text.contains("50"))
|
||||
}
|
||||
|
||||
@Test("Plus nur, wenn ausdrücklich gewünscht")
|
||||
func vorzeichenstil() {
|
||||
let positiv = Geldbetrag(cents: 500, waehrung: "EUR")
|
||||
#expect(positiv.formatiert(vorzeichen: .immer).hasPrefix("+"))
|
||||
#expect(positiv.formatiert().hasPrefix("+") == false)
|
||||
}
|
||||
|
||||
@Test("Summieren geht nur in derselben Währung")
|
||||
func summieren() {
|
||||
let a = Geldbetrag(cents: 100, waehrung: "EUR")
|
||||
let b = Geldbetrag(cents: 250, waehrung: "EUR")
|
||||
#expect((a + b).cents == 350)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Zeitangaben
|
||||
|
||||
@Suite("Zeitangaben")
|
||||
struct ZeitangabenTests {
|
||||
|
||||
@Test("Ein Tagesdatum überlebt Hin- und Rückweg")
|
||||
func tagesdatum() throws {
|
||||
let tag = try #require(Tagesdatum("2026-07-14"))
|
||||
#expect(tag.text == "2026-07-14")
|
||||
#expect(Tagesdatum(tag.datum).text == "2026-07-14")
|
||||
}
|
||||
|
||||
@Test("Tage lassen sich vergleichen")
|
||||
func reihenfolge() throws {
|
||||
let frueher = try #require(Tagesdatum("2026-01-31"))
|
||||
let spaeter = try #require(Tagesdatum("2026-02-01"))
|
||||
#expect(frueher < spaeter)
|
||||
}
|
||||
|
||||
@Test("Kaputte Tagesangaben ergeben nichts")
|
||||
func kaputterTag() {
|
||||
#expect(Tagesdatum("14.07.2026") == nil)
|
||||
#expect(Tagesdatum("") == nil)
|
||||
#expect(Tagesdatum("2026-13-45") == nil)
|
||||
}
|
||||
|
||||
@Test("Zeitstempel mit und ohne Sekundenbruchteile")
|
||||
func zeitpunkte() {
|
||||
// Das Backend schickt beide Formen, je nach Feld.
|
||||
#expect(Zeitpunkt("2026-07-14T10:11:12Z") != nil)
|
||||
#expect(Zeitpunkt("2026-07-14T10:11:12.345Z") != nil)
|
||||
#expect(Zeitpunkt("2026-07-14T10:11:12+02:00") != nil)
|
||||
// Python schreibt ohne Zone, wenn keine gesetzt ist.
|
||||
#expect(Zeitpunkt("2026-07-14T10:11:12") != nil)
|
||||
// Mikrosekunden, wie SQLite sie ablegt.
|
||||
#expect(Zeitpunkt("2026-07-14T10:11:12.345678") != nil)
|
||||
#expect(Zeitpunkt("übermorgen") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server-Adresse
|
||||
|
||||
@Suite("Server-Adresse")
|
||||
struct AdressTests {
|
||||
|
||||
@Test("Ohne Schema wird https ergänzt")
|
||||
func schemaErgaenzen() throws {
|
||||
let adresse = try #require(AppZustand.deuteAdresse("budget.beispiel.com"))
|
||||
#expect(adresse.absoluteString == "https://budget.beispiel.com")
|
||||
}
|
||||
|
||||
@Test("Ein angehängter Pfad fliegt raus")
|
||||
func pfadWeg() throws {
|
||||
// Der Nutzer kopiert gern die Adresse aus der Browserzeile, samt Pfad.
|
||||
// Die API hängt aber immer unter /api an der Wurzel.
|
||||
let adresse = try #require(AppZustand.deuteAdresse("https://budget.beispiel.com/buchungen?x=1"))
|
||||
#expect(adresse.absoluteString == "https://budget.beispiel.com")
|
||||
}
|
||||
|
||||
@Test("http bleibt http")
|
||||
func schemaBleibt() throws {
|
||||
let adresse = try #require(AppZustand.deuteAdresse("http://192.168.1.5:8000"))
|
||||
#expect(adresse.absoluteString == "http://192.168.1.5:8000")
|
||||
}
|
||||
|
||||
@Test("Unsinn ergibt keine Adresse")
|
||||
func unsinn() {
|
||||
#expect(AppZustand.deuteAdresse("") == nil)
|
||||
#expect(AppZustand.deuteAdresse(" ") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Symbole
|
||||
|
||||
@Suite("Symbole")
|
||||
struct SymbolTests {
|
||||
|
||||
@Test("Bekannte Namen werden übersetzt")
|
||||
func bekannt() {
|
||||
#expect(Symbole.sf("wallet") == "wallet.bifold")
|
||||
#expect(Symbole.sf("shopping-cart") == "cart")
|
||||
}
|
||||
|
||||
@Test("Unbekanntes wird zum Platzhalter, nie zu nichts")
|
||||
func unbekannt() {
|
||||
// Eine leere Stelle in der Liste sähe aus wie ein Fehler.
|
||||
#expect(Symbole.sf("gibtesnicht") == Symbole.platzhalter)
|
||||
#expect(Symbole.sf(nil) == Symbole.platzhalter)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fehlerantworten
|
||||
|
||||
@Suite("Fehlerantworten")
|
||||
struct FehlerTests {
|
||||
|
||||
@Test("detail als Zeichenkette")
|
||||
func alsText() {
|
||||
let daten = Data(#"{"detail":"Nicht gefunden"}"#.utf8)
|
||||
#expect(Fehlerantwort.ausDaten(daten) == "Nicht gefunden")
|
||||
}
|
||||
|
||||
@Test("detail als Liste, wie bei 422")
|
||||
func alsListe() {
|
||||
// Genau hier gehen Meldungen verloren, wenn der Decoder nur eine
|
||||
// Zeichenkette kennt.
|
||||
let daten = Data(#"{"detail":[{"loc":["body","amount_cents"],"msg":"muss groesser als 0 sein","type":"value_error"}]}"#.utf8)
|
||||
#expect(Fehlerantwort.ausDaten(daten) == "muss groesser als 0 sein")
|
||||
}
|
||||
|
||||
@Test("Was gar kein Fehlerkörper ist, ergibt leeren Text statt eines Absturzes")
|
||||
func unbrauchbar() {
|
||||
#expect(Fehlerantwort.ausDaten(Data("<html>".utf8)) == "")
|
||||
#expect(Fehlerantwort.ausDaten(Data()) == "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modelle gegen echte Antworten
|
||||
|
||||
@Suite("Modelle")
|
||||
struct ModellTests {
|
||||
|
||||
@Test("Ein Konto, wie der Server es schickt")
|
||||
func konto() throws {
|
||||
// Abgenommen von einer echten Antwort aus GET /api/accounts.
|
||||
let roh = """
|
||||
{"id":3,"name":"Revolut CHF","type":"girokonto","currency":"CHF",
|
||||
"icon":"wallet","color":"#3f51b5","opening_balance_cents":0,
|
||||
"opening_date":"2026-01-01","credit_limit_cents":null,
|
||||
"is_locked":false,"is_archived":false,"position":2,
|
||||
"savings_category_id":null,
|
||||
"identifiers":[{"id":7,"kind":"iban","value":"CH93..."}],
|
||||
"balance_cents":-1043,"balance_base_cents":-1043,
|
||||
"bank_balance_cents":null,"bank_balance_at":null,
|
||||
"bank_balance_vergleichbar":false}
|
||||
"""
|
||||
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
|
||||
#expect(konto.name == "Revolut CHF")
|
||||
#expect(konto.type == .girokonto)
|
||||
#expect(konto.saldo.cents == -1043)
|
||||
#expect(konto.identifiers.first?.kind == .iban)
|
||||
#expect(konto.weichtVonDerBankAb == false)
|
||||
}
|
||||
|
||||
@Test("Ein Banksaldo, der abweicht, wird gemeldet")
|
||||
func saldoWeichtAb() throws {
|
||||
let roh = """
|
||||
{"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet",
|
||||
"color":"#6b7480","opening_balance_cents":0,"opening_date":null,
|
||||
"credit_limit_cents":null,"is_locked":false,"is_archived":false,
|
||||
"position":0,"savings_category_id":null,"identifiers":[],
|
||||
"balance_cents":1000,"balance_base_cents":1000,
|
||||
"bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z",
|
||||
"bank_balance_vergleichbar":true}
|
||||
"""
|
||||
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
|
||||
#expect(konto.weichtVonDerBankAb)
|
||||
}
|
||||
|
||||
@Test("Ein nicht vergleichbarer Banksaldo loest keinen Hinweis aus")
|
||||
func saldoNichtVergleichbar() throws {
|
||||
// Ein verfuegbarer Saldo enthaelt Vorgemerktes - dass er abweicht,
|
||||
// ist normal und keine Meldung wert.
|
||||
let roh = """
|
||||
{"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet",
|
||||
"color":"#6b7480","opening_balance_cents":0,"opening_date":null,
|
||||
"credit_limit_cents":null,"is_locked":false,"is_archived":false,
|
||||
"position":0,"savings_category_id":null,"identifiers":[],
|
||||
"balance_cents":1000,"balance_base_cents":1000,
|
||||
"bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z",
|
||||
"bank_balance_vergleichbar":false}
|
||||
"""
|
||||
let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8))
|
||||
#expect(konto.weichtVonDerBankAb == false)
|
||||
}
|
||||
|
||||
@Test("Einstellungen mit Lücken fallen auf sinnvolle Werte zurück")
|
||||
func einstellungen() {
|
||||
let leer = Einstellungen([])
|
||||
#expect(leer.basiswaehrung == "EUR")
|
||||
#expect(leer.budgetStarttag == 1)
|
||||
|
||||
let gesetzt = Einstellungen([
|
||||
Einstellung(key: "base_currency", value: "CHF"),
|
||||
Einstellung(key: "budget_start_day", value: "25"),
|
||||
Einstellung(key: "active_currencies", value: "EUR, CHF ,USD"),
|
||||
])
|
||||
#expect(gesetzt.basiswaehrung == "CHF")
|
||||
#expect(gesetzt.budgetStarttag == 25)
|
||||
#expect(gesetzt.aktiveWaehrungen == ["EUR", "CHF", "USD"])
|
||||
}
|
||||
|
||||
@Test("Ein unsinniger Starttag wird eingefangen")
|
||||
func starttagGrenzen() {
|
||||
// Das Backend begrenzt auf 1..31; die App darf sich nicht darauf
|
||||
// verlassen, dass in der Einstellung nichts anderes steht.
|
||||
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "99")])
|
||||
.budgetStarttag == 31)
|
||||
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "0")])
|
||||
.budgetStarttag == 1)
|
||||
#expect(Einstellungen([Einstellung(key: "budget_start_day", value: "weiss nicht")])
|
||||
.budgetStarttag == 1)
|
||||
}
|
||||
|
||||
@Test("Die Serverkennung erkennt einen fremden Server")
|
||||
func serverkennung() throws {
|
||||
let echt = try JSONDecoder().decode(
|
||||
Serverkennung.self,
|
||||
from: Data(#"{"name":"Certain Finance","version":"0.1.0","git_sha":"abc"}"#.utf8))
|
||||
#expect(echt.istCertain)
|
||||
|
||||
let fremd = try JSONDecoder().decode(
|
||||
Serverkennung.self,
|
||||
from: Data(#"{"name":"Nextcloud","version":"29","git_sha":null}"#.utf8))
|
||||
#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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/* Certain für iOS – Deutsch (Grundsprache)
|
||||
|
||||
Die Schlüssel sind nach Bereich benannt. Bitte beim Ergänzen die englische
|
||||
Fassung gleich mitpflegen; im Web stehen de.json und en.json zeilengleich,
|
||||
und das ist der Grund, warum dort nie etwas fehlt. */
|
||||
|
||||
/* --- Allgemein --- */
|
||||
"allgemein.nochmal" = "Nochmal versuchen";
|
||||
"allgemein.abbrechen" = "Abbrechen";
|
||||
"allgemein.abmelden" = "Abmelden";
|
||||
"allgemein.unbekannt" = "Unbekannt";
|
||||
"allgemein.sichern" = "Sichern";
|
||||
"allgemein.ok" = "OK";
|
||||
"allgemein.fertig" = "Fertig";
|
||||
"allgemein.bearbeiten" = "Bearbeiten";
|
||||
"allgemein.loeschen" = "Löschen";
|
||||
|
||||
/* --- Reiter --- */
|
||||
"reiter.dashboard" = "Übersicht";
|
||||
"reiter.budget" = "Budget";
|
||||
"reiter.buchungen" = "Buchungen";
|
||||
"reiter.abos" = "Verträge";
|
||||
"reiter.arbeitsliste" = "Eingang";
|
||||
"reiter.konten" = "Konten";
|
||||
"reiter.einstellungen" = "Einstellungen";
|
||||
|
||||
/* --- Fehler --- */
|
||||
"fehler.titel" = "Das hat nicht geklappt";
|
||||
"fehler.unbekannt" = "Unbekannter Fehler.";
|
||||
"fehler.keineVerbindung" = "Keine Verbindung zum Server: %@";
|
||||
"fehler.keinCertainServer" = "Unter dieser Adresse antwortet zwar etwas, aber kein Certain.";
|
||||
"fehler.nichtAngemeldet" = "Die Anmeldung dieses Geräts gilt nicht mehr. Vermutlich wurde sie am Rechner widerrufen.";
|
||||
"fehler.passwortWechseln" = "Bevor es weitergeht, muss das Passwort gewechselt werden.";
|
||||
"fehler.zweiterFaktor" = "Dieses Konto braucht einen zweiten Faktor.";
|
||||
"fehler.nichtGefunden" = "Gibt es nicht.";
|
||||
"fehler.server" = "Der Server meldet einen Fehler (%d).";
|
||||
"fehler.antwortUnlesbar" = "Die Antwort des Servers passt nicht zu dem, was die App erwartet: %@";
|
||||
|
||||
/* --- Offline --- */
|
||||
"stand.hinweis" = "Stand von %@ – der Server ist gerade nicht erreichbar.";
|
||||
|
||||
/* --- Server-Adresse --- */
|
||||
"server.titel" = "Mit Certain verbinden";
|
||||
"server.ueberschrift" = "Adresse";
|
||||
"server.platzhalter" = "budget.beispiel.com";
|
||||
"server.erklaerung" = "Dieselbe Adresse, unter der du Certain im Browser öffnest. Ohne https davor geht auch.";
|
||||
"server.verbinden" = "Verbinden";
|
||||
"server.keineAdresse" = "Das sieht nicht nach einer Adresse aus.";
|
||||
|
||||
/* --- Anmeldung --- */
|
||||
"anmeldung.titel" = "Anmelden";
|
||||
"anmeldung.benutzer" = "Benutzername";
|
||||
"anmeldung.passwort" = "Passwort";
|
||||
"anmeldung.anmelden" = "Anmelden";
|
||||
"anmeldung.erklaerung" = "Nur dieses eine Mal. Danach merkt sich das Gerät einen eigenen Schlüssel, und dein Passwort wird nirgends gespeichert.";
|
||||
"anmeldung.andererServer" = "Anderen Server wählen";
|
||||
"anmeldung.aktuellerServer" = "Verbunden mit %@";
|
||||
"anmeldung.markeOhneSchluessel" = "Der Server hat keinen Schlüssel zurückgegeben. Bitte nochmal versuchen.";
|
||||
|
||||
/* --- Zweiter Faktor --- */
|
||||
"zweifaktor.titel" = "Zweiter Faktor";
|
||||
"zweifaktor.ueberschrift" = "Code";
|
||||
"zweifaktor.platzhalterCode" = "123456";
|
||||
"zweifaktor.platzhalterWiederherstellung" = "ABCDE-FGHIJ";
|
||||
"zweifaktor.erklaerungCode" = "Die sechs Ziffern aus deiner Authenticator-App.";
|
||||
"zweifaktor.erklaerungWiederherstellung" = "Einer der Codes, die du beim Einrichten bekommen hast. Jeder gilt nur einmal.";
|
||||
"zweifaktor.bestaetigen" = "Bestätigen";
|
||||
"zweifaktor.dochWiederherstellung" = "Stattdessen einen Wiederherstellungscode benutzen";
|
||||
"zweifaktor.dochCode" = "Stattdessen den Code aus der App benutzen";
|
||||
"zweifaktor.abbrechen" = "Anmeldung abbrechen";
|
||||
"zweifaktor.verbraucht" = "Zu viele Fehlversuche. Bitte von vorn anmelden.";
|
||||
|
||||
/* --- Zweiter Faktor fehlt noch --- */
|
||||
"zweifaktorEinrichten.titel" = "Zweiter Faktor nötig";
|
||||
"zweifaktorEinrichten.erklaerung" = "Für dieses Konto verlangt die Richtlinie einen zweiten Faktor. Einrichten lässt er sich bisher nur im Browser – in der App kommt das noch.";
|
||||
"zweifaktorEinrichten.imBrowser" = "Im Browser öffnen";
|
||||
|
||||
/* --- Passwortwechsel --- */
|
||||
"passwort.titel" = "Passwort wechseln";
|
||||
"passwort.bisher" = "Bisheriges Passwort";
|
||||
"passwort.neu" = "Neues Passwort";
|
||||
"passwort.wiederholen" = "Noch einmal";
|
||||
"passwort.erklaerung" = "Für dieses Konto steht ein Passwortwechsel an. Vorher lässt der Server nichts anderes zu.";
|
||||
"passwort.stimmtNichtUeberein" = "Die beiden stimmen nicht überein.";
|
||||
"passwort.speichern" = "Passwort wechseln";
|
||||
|
||||
/* --- Konten --- */
|
||||
"konten.titel" = "Konten";
|
||||
"konten.leerTitel" = "Noch keine Konten";
|
||||
"konten.leerErklaerung" = "Lege dein erstes Konto im Browser an – in der App kommt das in einer späteren Stufe.";
|
||||
"konten.archivierteZeigen" = "Archivierte zeigen";
|
||||
"konten.archiviert" = "archiviert";
|
||||
"konten.gebunden" = "Gebundenes Geld";
|
||||
"konten.gebundenSumme" = "Davon gebunden";
|
||||
"konten.verfuegbar" = "Verfügbar";
|
||||
"konten.bankWeichtAb" = "Die Bank meldet einen anderen Saldo";
|
||||
|
||||
/* --- Kontoarten --- */
|
||||
"kontoart.girokonto" = "Girokonto";
|
||||
"kontoart.sparkonto" = "Sparkonto";
|
||||
"kontoart.bargeld" = "Bargeld";
|
||||
"kontoart.kreditkarte" = "Kreditkarte";
|
||||
"kontoart.depot" = "Depot";
|
||||
"kontoart.sonstiges" = "Sonstiges";
|
||||
|
||||
/* --- Einstellungen --- */
|
||||
"einstellungen.zugang" = "Zugang";
|
||||
"einstellungen.angemeldetAls" = "Angemeldet als";
|
||||
"einstellungen.server" = "Server";
|
||||
"einstellungen.markeErklaerung" = "Dieses Gerät hat einen eigenen Schlüssel. Du kannst ihn am Rechner unter Einstellungen → Gerätemarken jederzeit widerrufen; dann meldet sich die App sofort ab.";
|
||||
"einstellungen.ausDemServer" = "Vom Server";
|
||||
"einstellungen.hauptwaehrung" = "Hauptwährung";
|
||||
"einstellungen.budgetStarttag" = "Budget beginnt am";
|
||||
"einstellungen.abmeldenFrage" = "Von diesem Gerät abmelden?";
|
||||
"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";
|
||||
@@ -0,0 +1,377 @@
|
||||
/* Certain for iOS – English
|
||||
|
||||
Keys mirror de.lproj line for line. Keep them that way: in the web app
|
||||
de.json and en.json are the same length, and that is why nothing is ever
|
||||
missing there. */
|
||||
|
||||
/* --- General --- */
|
||||
"allgemein.nochmal" = "Try again";
|
||||
"allgemein.abbrechen" = "Cancel";
|
||||
"allgemein.abmelden" = "Sign out";
|
||||
"allgemein.unbekannt" = "Unknown";
|
||||
"allgemein.sichern" = "Save";
|
||||
"allgemein.ok" = "OK";
|
||||
"allgemein.fertig" = "Done";
|
||||
"allgemein.bearbeiten" = "Edit";
|
||||
"allgemein.loeschen" = "Delete";
|
||||
|
||||
/* --- Tabs --- */
|
||||
"reiter.dashboard" = "Overview";
|
||||
"reiter.budget" = "Budget";
|
||||
"reiter.buchungen" = "Transactions";
|
||||
"reiter.abos" = "Subscriptions";
|
||||
"reiter.arbeitsliste" = "Inbox";
|
||||
"reiter.konten" = "Accounts";
|
||||
"reiter.einstellungen" = "Settings";
|
||||
|
||||
/* --- Errors --- */
|
||||
"fehler.titel" = "That did not work";
|
||||
"fehler.unbekannt" = "Unknown error.";
|
||||
"fehler.keineVerbindung" = "Cannot reach the server: %@";
|
||||
"fehler.keinCertainServer" = "Something answers at this address, but it is not Certain.";
|
||||
"fehler.nichtAngemeldet" = "This device is no longer signed in. It was probably revoked on the desktop.";
|
||||
"fehler.passwortWechseln" = "The password has to be changed before anything else.";
|
||||
"fehler.zweiterFaktor" = "This account needs a second factor.";
|
||||
"fehler.nichtGefunden" = "Does not exist.";
|
||||
"fehler.server" = "The server reports an error (%d).";
|
||||
"fehler.antwortUnlesbar" = "The server's answer does not match what the app expects: %@";
|
||||
|
||||
/* --- Offline --- */
|
||||
"stand.hinweis" = "As of %@ – the server cannot be reached right now.";
|
||||
|
||||
/* --- Server address --- */
|
||||
"server.titel" = "Connect to Certain";
|
||||
"server.ueberschrift" = "Address";
|
||||
"server.platzhalter" = "budget.example.com";
|
||||
"server.erklaerung" = "The same address you open Certain with in your browser. Leaving off https is fine.";
|
||||
"server.verbinden" = "Connect";
|
||||
"server.keineAdresse" = "That does not look like an address.";
|
||||
|
||||
/* --- Sign in --- */
|
||||
"anmeldung.titel" = "Sign in";
|
||||
"anmeldung.benutzer" = "Username";
|
||||
"anmeldung.passwort" = "Password";
|
||||
"anmeldung.anmelden" = "Sign in";
|
||||
"anmeldung.erklaerung" = "Just this once. Afterwards the device keeps a key of its own, and your password is stored nowhere.";
|
||||
"anmeldung.andererServer" = "Choose a different server";
|
||||
"anmeldung.aktuellerServer" = "Connected to %@";
|
||||
"anmeldung.markeOhneSchluessel" = "The server returned no key. Please try again.";
|
||||
|
||||
/* --- Second factor --- */
|
||||
"zweifaktor.titel" = "Second factor";
|
||||
"zweifaktor.ueberschrift" = "Code";
|
||||
"zweifaktor.platzhalterCode" = "123456";
|
||||
"zweifaktor.platzhalterWiederherstellung" = "ABCDE-FGHIJ";
|
||||
"zweifaktor.erklaerungCode" = "The six digits from your authenticator app.";
|
||||
"zweifaktor.erklaerungWiederherstellung" = "One of the codes you got when setting this up. Each works once.";
|
||||
"zweifaktor.bestaetigen" = "Confirm";
|
||||
"zweifaktor.dochWiederherstellung" = "Use a recovery code instead";
|
||||
"zweifaktor.dochCode" = "Use the code from the app instead";
|
||||
"zweifaktor.abbrechen" = "Cancel sign-in";
|
||||
"zweifaktor.verbraucht" = "Too many wrong attempts. Please start over.";
|
||||
|
||||
/* --- Second factor missing --- */
|
||||
"zweifaktorEinrichten.titel" = "Second factor required";
|
||||
"zweifaktorEinrichten.erklaerung" = "The policy requires a second factor for this account. Setting one up still only works in the browser; the app will get it later.";
|
||||
"zweifaktorEinrichten.imBrowser" = "Open in browser";
|
||||
|
||||
/* --- Password change --- */
|
||||
"passwort.titel" = "Change password";
|
||||
"passwort.bisher" = "Current password";
|
||||
"passwort.neu" = "New password";
|
||||
"passwort.wiederholen" = "Once more";
|
||||
"passwort.erklaerung" = "This account has to change its password. The server allows nothing else until then.";
|
||||
"passwort.stimmtNichtUeberein" = "These two do not match.";
|
||||
"passwort.speichern" = "Change password";
|
||||
|
||||
/* --- Accounts --- */
|
||||
"konten.titel" = "Accounts";
|
||||
"konten.leerTitel" = "No accounts yet";
|
||||
"konten.leerErklaerung" = "Create your first account in the browser; the app will get this in a later stage.";
|
||||
"konten.archivierteZeigen" = "Show archived";
|
||||
"konten.archiviert" = "archived";
|
||||
"konten.gebunden" = "Money set aside";
|
||||
"konten.gebundenSumme" = "Of which set aside";
|
||||
"konten.verfuegbar" = "Available";
|
||||
"konten.bankWeichtAb" = "The bank reports a different balance";
|
||||
|
||||
/* --- Account types --- */
|
||||
"kontoart.girokonto" = "Current account";
|
||||
"kontoart.sparkonto" = "Savings account";
|
||||
"kontoart.bargeld" = "Cash";
|
||||
"kontoart.kreditkarte" = "Credit card";
|
||||
"kontoart.depot" = "Securities account";
|
||||
"kontoart.sonstiges" = "Other";
|
||||
|
||||
/* --- Settings --- */
|
||||
"einstellungen.zugang" = "Access";
|
||||
"einstellungen.angemeldetAls" = "Signed in as";
|
||||
"einstellungen.server" = "Server";
|
||||
"einstellungen.markeErklaerung" = "This device holds a key of its own. You can revoke it any time on the desktop under Settings → Device keys; the app signs out immediately.";
|
||||
"einstellungen.ausDemServer" = "From the server";
|
||||
"einstellungen.hauptwaehrung" = "Main currency";
|
||||
"einstellungen.budgetStarttag" = "Budget starts on";
|
||||
"einstellungen.abmeldenFrage" = "Sign out on this device?";
|
||||
"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";
|
||||
@@ -0,0 +1,588 @@
|
||||
# Übergabe: iOS-App für Certain
|
||||
|
||||
**Für den nächsten Agenten.** Diese Datei ersetzt die Unterhaltung, in der die
|
||||
App geplant wurde. Sie enthält alles, was du brauchst, ohne sie gelesen zu
|
||||
haben: was entschieden ist, was fertig ist, was als Nächstes dran ist, und was
|
||||
ich über das Backend herausgefunden habe. Lies sie ganz, bevor du anfängst —
|
||||
die Abschnitte „Was das Backend vorgibt" und „Fallen" ersparen dir Tage.
|
||||
|
||||
Stand: 21.10.2026, Commit `d290930`. Alles committet und gepusht.
|
||||
|
||||
---
|
||||
|
||||
## 1. Worum es geht
|
||||
|
||||
Certain ist ein Haushaltsbuch: FastAPI-Backend (`backend/`) plus
|
||||
React-Weboberfläche (`web/`), die das Backend mit ausliefert. Dazu kommt eine
|
||||
native iOS-App in `ios/`, die sich über eine eingegebene Server-Adresse (wie
|
||||
`budget.scarriffle.com`) an dasselbe Backend hängt.
|
||||
|
||||
**Ziel: alles, was die Weboberfläche kann.** Budget, Buchungen, Konten, Abos,
|
||||
Dashboard, Arbeitsliste, Kategorien, Tags, Einstellungen, Bankabruf und der
|
||||
volle CSV-Import.
|
||||
|
||||
### Die Rahmenbedingung, die es nicht mehr gibt
|
||||
|
||||
Bis zum 21.10.2026 stand hier: es gibt kein Xcode, der Swift-Code wird blind
|
||||
geschrieben und nie übersetzt. **Das gilt nicht mehr.** Auf dieser Maschine
|
||||
liegt Xcode 27 mit Simulatoren für iOS 26.5 und 27, und `ios/project.yml`
|
||||
erzeugt das Projekt. Übersetzen, Tests und Starten im Simulator gehen hier
|
||||
direkt:
|
||||
|
||||
```
|
||||
cd ios && xcodegen generate # nach jeder neuen Datei
|
||||
xcodebuild -project Certain.xcodeproj -scheme Certain \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' build # oder test
|
||||
```
|
||||
|
||||
**Übersetze nach jeder Stufe selbst, statt dem Nutzer Fehler zu hinterlassen.**
|
||||
Die kleinen Stufen bleiben trotzdem richtig — nicht wegen der Fehlersuche,
|
||||
sondern damit jede Stufe für sich benutzbar ist.
|
||||
|
||||
---
|
||||
|
||||
## 2. Entscheidungen des Nutzers
|
||||
|
||||
Alle schon gefallen. **Nicht erneut fragen.**
|
||||
|
||||
| Frage | Festgelegt |
|
||||
|---|---|
|
||||
| Backend erweitern | Ja, kleine Ergänzungen — jede einzeln vorlegen, bevor du sie schreibst |
|
||||
| CSV-Import | **Voll, wie im Web** (trotz Abraten; steht deshalb als letzte Stufe) |
|
||||
| Zusätzlicher Umfang | Arbeitsliste/Eingang, Kategorien und Tags, Einstellungen, Bankabruf anstossen |
|
||||
| Dashboard | Feste iOS-Ansicht, kein Raster |
|
||||
| iOS-Mindestversion | **iOS 26** |
|
||||
| Ablieferung | Flache Ordner unter `ios/`; das Projekt entsteht seit dem 21.10. aus `project.yml` |
|
||||
| Anmeldung | App-Token (im Backend ergänzt, siehe unten) |
|
||||
| Erweiterungen | Teilen-Menü für Dateien, Kurzbefehle/Siri |
|
||||
| Sprachen | Deutsch **und** Englisch |
|
||||
| iPad | Richtig mitgedacht, zweispaltig |
|
||||
| Offline | Zuletzt Geladenes mit „Stand von …" |
|
||||
|
||||
**Der Nutzer will jede Entscheidung selbst treffen.** Wenn eine Frage auftaucht,
|
||||
die den Zuschnitt ändert: fragen, nicht annehmen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Was das Backend vorgibt
|
||||
|
||||
Die Befunde aus einer gründlichen Durchsicht. Sie bestimmen den ganzen Entwurf.
|
||||
|
||||
### Anmeldung
|
||||
|
||||
Die Web-API kennt **ausschliesslich ein Sitzungs-Cookie** (`get_current_user`
|
||||
in `backend/app/deps.py`). Kein Bearer, kein Token im Body. Die Sitzung liegt
|
||||
serverseitig in der Tabelle `sessions` (kein JWT), hält 30 Tage und
|
||||
**verlängert sich nicht**.
|
||||
|
||||
Anmeldung ist ein **Formular**, kein JSON: `POST /api/auth/login` mit
|
||||
`application/x-www-form-urlencoded`, Feldern `username` und `password`
|
||||
(`OAuth2PasswordRequestForm`). JSON gibt 422.
|
||||
|
||||
Zwei-Faktor: Login antwortet `{"status": "2fa_required"}` und setzt ein
|
||||
Pending-Cookie mit **10 Minuten** Laufzeit. Dann
|
||||
`POST /api/auth/2fa/verify` mit `{"code": "...", "recovery": false}`. Nach
|
||||
**fünf** Fehlversuchen ist die Zwischensitzung verbraucht.
|
||||
|
||||
Offene Pflichten sperren **jeden** Endpunkt mit `403` und einem
|
||||
maschinenlesbaren `detail`: `password_change_required` oder
|
||||
`mfa_setup_required`. Erreichbar bleiben nur `/health`, `/version`,
|
||||
`/auth/me`, `/auth/logout`, `/auth/login`, `/auth/change-password`, `/auth/2fa`.
|
||||
|
||||
### Alles hängt unter `/api`
|
||||
|
||||
`GET /api/version` → `{"name": "Certain Finance", ...}`. Das ist die
|
||||
Serverprüfung. **Wichtig:** Jeder unbekannte Pfad ausserhalb `/api` liefert mit
|
||||
Status 200 die Weboberfläche aus — nur `/api/version` kann einen fremden Server
|
||||
sicher ausschliessen.
|
||||
|
||||
### Fehlerformat
|
||||
|
||||
`{"detail": "..."}` — aber bei 422 ist `detail` eine **Liste** von
|
||||
Pydantic-Meldungen statt einer Zeichenkette. `Netzwerk/APIFehler.swift` fängt
|
||||
beides ab; wer das übersieht, verliert bei jedem Eingabefehler die Meldung.
|
||||
|
||||
Statuscodes: 401 nicht angemeldet, 403 verboten oder Pflicht offen, 404 auch
|
||||
für fremde IDs (bewusst, Mandanten-Isolation), **409 heisst „nachfragen"** und
|
||||
lässt sich oft mit `trotzdem=true` wiederholen, 400/422 ungültig.
|
||||
|
||||
### Beträge
|
||||
|
||||
**Alles sind Cent-Ganzzahlen.** Nirgends Fliesskomma (einzige Ausnahme:
|
||||
`QuickBookingIn.amount`). Drei Felder an einer Buchung:
|
||||
- `amount_cents` — in der **Kontowährung**
|
||||
- `amount_eur_cents` — beim Speichern **eingefrorener** EUR-Wert
|
||||
- `amount_base_cents` — bei jedem Lesen in die Hauptwährung umgerechnet
|
||||
|
||||
Der Client rechnet **nichts** davon selbst. Kontosalden, Budget-Soll/Ist,
|
||||
Vertrags-Monatswerte, Dashboard-Zahlen kommen alle fertig. Der Client
|
||||
formatiert nur — und rechnet Prozente und „Rest", die es in der API nicht gibt.
|
||||
|
||||
Achtung: `*_base_cents` kann still auf den nativen Wert zurückfallen, wenn kein
|
||||
Wechselkurs vorliegt. Die `ohne_kurs`-Listen in den Antworten sagen, wann das
|
||||
passiert. Wer sie nicht anzeigt, zeigt stillschweigend falsche Summen.
|
||||
|
||||
### Zeitstempel — die Falle, die mich Zeit gekostet hat
|
||||
|
||||
`created_at` und Konsorten kommen als `2026-10-20T14:18:15.780705` — **ohne
|
||||
Zeitzone, mit sechs Stellen Mikrosekunden**. Beide Standard-ISO8601-Leser von
|
||||
Foundation scheitern daran, weil sie eine Zone verlangen. `Modelle/Zeitangaben.swift`
|
||||
hat deshalb einen dritten Leser. Tagesdaten (`date`, `opening_date`) sind
|
||||
dagegen reine `"2026-07-14"`. Zwei getrennte Typen statt einer
|
||||
Decoder-Strategie — das ist Absicht, siehe Kommentar dort.
|
||||
|
||||
### Zwei Dinge, die fachlich im Frontend sitzen
|
||||
|
||||
Die App muss sie nachbauen, das Backend hilft nicht:
|
||||
|
||||
1. **Was ein Übertrag ist.** Entweder `type == "transfer"` **oder** die Buchung
|
||||
steht in `GET /api/transactions/links`. Es gibt zwei Mechanismen
|
||||
nebeneinander: ein Übertrag am Stück (eine Buchung mit `to_account_id`) und
|
||||
die Verknüpfungs-Schicht `TransferLink` (zwei getrennte Buchungen, wie die
|
||||
Bank sie liefert; keine davon wird verändert). Das Zusammenfassen beider
|
||||
Beine zu **einer** Anzeigezeile macht heute nur der Browser
|
||||
(`web/src/pages/Transactions.jsx`, Suche nach `paarZu`).
|
||||
2. **Welche Verträge in `aus_vertraegen_cents` stecken.** Das Backend liefert
|
||||
nur die Summe; welche Abos dahinterstehen, ermittelt das Frontend selbst aus
|
||||
`/subscriptions` nach `category_id`.
|
||||
|
||||
### Buchungsliste
|
||||
|
||||
`GET /api/transactions` kennt `account_id`, `category_id`, `uncategorized`,
|
||||
`type`, `date_from`, `date_to`, `limit` (Standard **5000**), `offset` und seit
|
||||
Ergänzung B auch `q`, `tag_id`, `subscription_id`. **Keine** wählbare
|
||||
Sortierung — immer Datum absteigend, dann ID absteigend.
|
||||
|
||||
Die Weboberfläche lädt weiter alles und filtert im Browser; sie wurde nicht
|
||||
umgestellt, weil sie so funktioniert. Die App soll die Filter benutzen.
|
||||
|
||||
### Was nicht in die App kommt
|
||||
|
||||
- **Bankkonten verbinden.** Die Bank leitet fest auf
|
||||
`{base_url}/bank/rueckkehr` zurück, registriert bei Enable Banking. Abrufen,
|
||||
Salden holen und Konten zuordnen läuft nativ — nur das Herstellen nicht.
|
||||
- **Postfach einrichten** (IMAP-Zugangsdaten) und **Sicherung/Wiederherstellung**
|
||||
(ersetzt die ganze Datenbank). Gehört an den Rechner.
|
||||
- **Benutzerverwaltung** (`/users`, `/policy`) — Administration, weggelassen,
|
||||
solange der Nutzer nicht widerspricht.
|
||||
- **Belege als Anhang gibt es nicht.** Im Datenmodell existiert nichts
|
||||
dergleichen. `POST /api/documents/preview` ist eine Einbahnstrasse: Bild rein,
|
||||
erkannter Text und Buchungsvorschläge raus, die Datei wird nicht gespeichert.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend-Ergänzungen
|
||||
|
||||
Vier geplant, **alle vier sind fertig**. Falls du eine fünfte brauchst: einzeln
|
||||
dem Nutzer vorlegen, bevor du sie schreibst.
|
||||
|
||||
### A — App-Token ✅ (Commit `8a7545b`)
|
||||
|
||||
`TokenScope` hat neben `quick_add` jetzt `app`. `get_current_user` akzeptiert
|
||||
den Header `X-Certain-Token`, wenn die Marke diese Reichweite hat. Kein
|
||||
Migrationsschritt nötig — `_enum()` legt Enums ohne DB-Bedingung ab.
|
||||
|
||||
Die Prüfung sitzt in `_session_user` (`backend/app/deps.py`), **nicht** in
|
||||
`get_current_user` — damit auch `enforce_account_state` die Marke sieht und
|
||||
eine App nicht an der Passwortwechsel-Pflicht vorbeikommt.
|
||||
|
||||
Zwei Dinge, die die Tests festhalten (`backend/tests/test_app_marke.py`,
|
||||
7 Stück):
|
||||
- Eine `quick_add`-Marke ist **nicht** still zum Vollzugang geworden. Sie gilt
|
||||
weiter nur an den drei `/quick`-Endpunkten.
|
||||
- Eine App-Marke darf **keine weitere ausstellen** (`require_cookie_session` an
|
||||
`POST /api/inbox/tokens`). Sonst stellte ein Dieb sich Ersatzschlüssel aus
|
||||
und das Widerrufen liefe ins Leere.
|
||||
|
||||
### B — Suche und Tag-Filter auf `/transactions` ✅
|
||||
|
||||
Drei Query-Parameter: `q` (Volltext über `payee`, `description`, `notes`),
|
||||
`tag_id`, `subscription_id`. Der Tag-Filter arbeitet mit einer Unterabfrage
|
||||
wie der `uncategorized`-Zweig daneben, nicht mit einem Join — sonst käme eine
|
||||
Buchung so oft zurück, wie sie Tags trägt.
|
||||
|
||||
Zwei Dinge, die die Tests festhalten (`backend/tests/test_buchungssuche.py`,
|
||||
7 Stück):
|
||||
- `%` und `_` im Suchtext sind **Zeichen, keine Platzhalter**. Sonst fände
|
||||
die Eingabe „50%" alles.
|
||||
- Ein fremder `tag_id` liefert **nichts**, nicht etwa alles. Der Filter
|
||||
schlägt auf `TransactionTag.owner_id` an.
|
||||
|
||||
Grenze: SQLite vergleicht bei LIKE nur ASCII ohne Rücksicht auf Gross- und
|
||||
Kleinschreibung. „Müller" findet „müller" nicht. Reicht, bis jemand es meldet.
|
||||
|
||||
Der lokale Name `q` im Router hiess vorher die Abfrage selbst; die heisst
|
||||
jetzt `abfrage`, damit der Query-Parameter `q` heissen kann.
|
||||
|
||||
### C — `GET /budget/template` liefert `modus` und `zuschlag_cents` ✅
|
||||
|
||||
Beide fehlten in der Antwort (`backend/app/routers/budgets.py`), obwohl das
|
||||
Modell sie führt und `PUT` sie zurückgibt. Jetzt stehen sie in jeder Zeile.
|
||||
Test: `test_vorlage_nennt_betriebsart_und_zuschlag` in `test_budget.py`.
|
||||
|
||||
Das war der stillere der beiden Fehler: Ein Client, der die Vorlage so liest,
|
||||
zeigt eine berechnete Zeile als feste **und schreibt sie beim nächsten
|
||||
Speichern auch als feste zurück**. Die Weboberfläche traf es nicht, weil sie
|
||||
die Betriebsart aus der Monatsansicht nimmt.
|
||||
|
||||
### D — Zwei Kopier-Lücken im Budget ✅
|
||||
|
||||
`copy_template_to_month` und `copy_month_to_template` in
|
||||
`backend/app/services/budget.py` übernahmen nur `planned_cents`. Ein
|
||||
„berechneter" Eintrag fiel beim Kopieren still auf „fest" zurück — mit der
|
||||
Zahl, die zufällig zuletzt in `planned_cents` stand.
|
||||
|
||||
`ensure_customized` nebenan machte es längst richtig; die zwei Kopierer ziehen
|
||||
jetzt nach. Dabei kam eine Frage auf, die vorher niemand stellen musste: Eine
|
||||
**berechnete** Zeile gehört auch mit `planned_cents == 0` in die Vorlage, ihre
|
||||
Zahl steht ja in den Verträgen. Eine **feste** Null bleibt draussen, sonst
|
||||
füllte sich die Vorlage mit Nullzeilen für jede je berührte Kategorie.
|
||||
|
||||
Drei Tests in `test_budget.py` halten beide Richtungen und die Null fest.
|
||||
|
||||
---
|
||||
|
||||
## 5. Was fertig ist — Stufen 1 bis 5 ✅
|
||||
|
||||
### Stufe 1 (Commit `6b86a6b`)
|
||||
|
||||
21 Swift-Dateien, rund 2600 Zeilen, in `ios/`:
|
||||
|
||||
| Ordner | Inhalt |
|
||||
|---|---|
|
||||
| `App/` | `CertainApp` (Einstieg), `AppZustand` (zentraler Zustand), `RootAnsicht` (Navigation) |
|
||||
| `Netzwerk/` | `CertainClient` (Actor um URLSession), `Endpunkte`, `APIFehler`, `Ablage` (Voreinstellungen + Schlüsselbund), `Zwischenspeicher` (offline), `Lader` |
|
||||
| `Modelle/` | `Grundmodelle` (Benutzer, Marke, Einstellungen, Serverkennung), `Konto`, `Zeitangaben` |
|
||||
| `Bausteine/` | `Geldbetrag`, `Darstellung` (Farben, Symbolzuordnung, Plakette), `Zustandsansichten` |
|
||||
| `Anmeldung/` | Server-Adresse, Anmeldung, zweiter Faktor, Passwortwechsel |
|
||||
| `Konten/` | `KontenListe` — die erste Fachansicht |
|
||||
| `Einstellungen/` | Platzhalter: zeigt den Zugang und lässt abmelden |
|
||||
| `Texte/` | `de.lproj` und `en.lproj`, zeilengleich |
|
||||
| `Tests/` | `GrundlagenTests` — reine Logik, Swift Testing, 28 Stück |
|
||||
| `project.yml` | XcodeGen-Spezifikation, erzeugt `Certain.xcodeproj` |
|
||||
|
||||
### Wie die Anmeldung abläuft
|
||||
|
||||
1. Nutzer gibt die Adresse ein → `GET /api/version` prüft, ob dort Certain läuft.
|
||||
2. Nutzer meldet sich an → normales Cookie, inklusive 2FA und
|
||||
Pflicht-Passwortwechsel.
|
||||
3. **Noch in derselben Sitzung** legt die App über `POST /api/inbox/tokens` mit
|
||||
`scope: "app"` eine Marke an und legt den Klartext in den **Schlüsselbund**.
|
||||
4. Danach trägt nur noch der Header. Das Cookie wird weggeworfen
|
||||
(`vergissCookies()`).
|
||||
|
||||
Kein Passwort auf dem Gerät, kein Ablauf nach 30 Tagen, einzeln widerrufbar.
|
||||
|
||||
### Was geprüft ist
|
||||
|
||||
Jedes Modellfeld wurde gegen **echte Antworten eines laufenden Backends**
|
||||
verglichen (`/api/version`, `/api/auth/me`, `/api/accounts`, `/api/settings`,
|
||||
`/api/inbox/tokens`). Alle Namen stimmen. **Mach das bei jeder weiteren Stufe
|
||||
genauso** — das ist die einzige Prüfung, die beim blinden Schreiben wirklich
|
||||
etwas fängt. Wie: Ein kleines Python-Skript mit `TestClient` gegen eine
|
||||
Wegwerf-Datenbank, Felder flach ausgeben, mit den `CodingKeys` vergleichen.
|
||||
(Das Passwort der Dev-Datenbank unter `backend/data/app.db` ist unbekannt —
|
||||
nicht raten, eine frische Testdatenbank nehmen.)
|
||||
|
||||
### Dass es übersetzt, ist inzwischen geprüft
|
||||
|
||||
Am 21.10.2026 gebaut, getestet und gestartet (Commit `ffe1870`). Genau **ein**
|
||||
Übersetzungsfehler in 2600 blind geschriebenen Zeilen: der Vorgabewert
|
||||
`[String: String] = []` in `CertainClient.schickeDatei` musste `[:]` heissen.
|
||||
Sonst keine Fehler, keine Warnung im eigenen Code. Die 28 Tests laufen durch,
|
||||
die App startet im Simulator bei der Serverabfrage.
|
||||
|
||||
**Noch nicht geprüft: der Weg gegen ein echtes Backend.** Anmelden, zweiter
|
||||
Faktor, Marke anlegen, Kontenliste — das lief bisher nur gegen die Modelle,
|
||||
nicht gegen einen laufenden Server. Siehe „Klartext-HTTP" unten.
|
||||
|
||||
### Stufe 2 — Lesen ✅ (Commit `bf437b3`)
|
||||
|
||||
Vier neue Reiter, dazu die Modelle und Endpunkte darunter:
|
||||
|
||||
| Ordner | Inhalt |
|
||||
|---|---|
|
||||
| `Dashboard/` | `DashboardAnsicht` — vier feste Karten, **ein** Abruf |
|
||||
| `Budget/` | `BudgetAnsicht` — Soll/Ist je Gruppe, blätterbar |
|
||||
| `Buchungen/` | `BuchungsListe` — Suche und Filter im Server |
|
||||
| `Abos/` | `AboListe` — Monats- und Jahressumme, Fälligkeit |
|
||||
| `Modelle/` | `Buchung`, `Budget`, `Abo`, `Kategorie`, `Dashboard` |
|
||||
|
||||
Drei Entscheidungen, die man kennen sollte, bevor man daran weiterbaut:
|
||||
|
||||
1. **Das Dashboard holt alles in einem Wert** (`Dashboarddaten`). Vier
|
||||
getrennte Zwischenspeicher könnten offline aus vier verschiedenen
|
||||
Momenten stammen und sich widersprechen.
|
||||
2. **Das Nachschlagewerk sitzt im `AppZustand`**, nicht in den Ansichten.
|
||||
`Nachschlag` hält Kategorien, Schlagwörter und Konten; jede Liste braucht
|
||||
zu einer ID den Namen, das Symbol und die Farbe.
|
||||
3. **Die Buchungsliste sucht erst beim Absenden.** Je Tastendruck zu fragen
|
||||
hiesse, bei „Supermarkt" zehn Abrufe zu schicken, von denen neun niemanden
|
||||
interessieren.
|
||||
|
||||
**Die Übertrags-Paarung steckt in `Buchungszeile.zeilen`** (`Modelle/Buchung.swift`).
|
||||
Zwei Dinge daran sind leicht falsch zu machen, beide durch Tests festgehalten:
|
||||
- Zusammengefasst wird **nur, wenn beide Beine in der Liste stehen**. Sonst
|
||||
liesse ein Kontofilter Zeilen verschwinden, die zu diesem Konto gehören.
|
||||
- Die Richtung kommt aus `richtung` der Verknüpfung, **nicht aus dem
|
||||
Vorzeichen** — manche Banken liefern beide Beine positiv.
|
||||
|
||||
### Die Modellprüfung ist jetzt ein Test
|
||||
|
||||
`backend/scripts/erzeuge_ios_fixture.py` legt eine Wegwerfdatenbank an,
|
||||
befüllt sie so, dass jedes Feld einmal belegt ist, und schreibt 15 echte
|
||||
Antworten nach `ios/Tests/Fixtures/antworten.json`. Der Swift-Test
|
||||
`Serverantworten` entschlüsselt daraus jedes Modell.
|
||||
|
||||
**Das ersetzt die Handarbeit, die hier früher beschrieben stand.** Nach jeder
|
||||
Änderung an einem Schema das Skript neu laufen lassen und die Datei mit
|
||||
committen:
|
||||
|
||||
```
|
||||
cd backend && .venv/bin/python -m scripts.erzeuge_ios_fixture
|
||||
```
|
||||
|
||||
Wer ein Modell ergänzt, nimmt seinen Endpunkt in `PFADE` auf und schreibt eine
|
||||
Zeile im Test dazu. Das ist die einzige Prüfung, die einen vertippten
|
||||
Feldnamen fängt — der übersetzt anstandslos.
|
||||
|
||||
### Stufe 3 — Schreiben ✅ (Commit `82db09e`)
|
||||
|
||||
Buchungen, Budget-Zeilen, Verträge und Konten lassen sich anlegen, ändern und
|
||||
löschen.
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| `Modelle/Eingaben.swift` | Was zum Server hinausgeht |
|
||||
| `Bausteine/Formular.swift` | Speichervorgang, Betragsfeld, Auswahlfelder |
|
||||
| `Buchungen/BuchungFormular.swift` | Anlegen **und** Ändern in einer Maske |
|
||||
| `Budget/BudgetZeileBearbeiten.swift` | Monat oder Vorlage, dieselbe Maske |
|
||||
| `Abos/AboFormular.swift`, `Konten/KontoFormular.swift` | ebenso |
|
||||
|
||||
**Ein Formular je Sache, nicht zwei.** Anlegen und Ändern teilen sich die
|
||||
Maske; was sich unterscheidet, steht in einem `Zweck`-Typ. Zwei Masken
|
||||
nebeneinander wären genau die Stelle, an der sie auseinanderlaufen.
|
||||
|
||||
#### Vier Regeln, die man nicht sieht, aber merkt, wenn sie fehlen
|
||||
|
||||
1. **Eine Teiländerung schickt nur, was gesetzt ist.** Ginge ein nicht
|
||||
angefasstes Feld als `null` hinaus, löschte jedes Speichern den Empfänger,
|
||||
die Notiz und die Kategorie gleich mit. Swift macht das von selbst
|
||||
(`encodeIfPresent` für Optionals) — der Test dazu bleibt trotzdem stehen,
|
||||
die Eigenschaft ist zu wichtig für eine ungeprüfte Spracheigenheit.
|
||||
2. **Eine leere Tag-Liste geht sehr wohl mit.** Sie heisst „alle abnehmen" und
|
||||
darf nicht mit „nicht angefasst" zusammenfallen.
|
||||
3. **Ein Übertrag bekommt keine Kategorie.** Das Geld wechselt nur die Tasche.
|
||||
4. **Bei „berechnet" bleibt `planned_cents` unberührt.** Das Backend liest es
|
||||
dann nicht, und es zu überschreiben verlöre den Wert, auf den ein
|
||||
Zurückschalten auf „fest" zurückfallen soll.
|
||||
|
||||
Die **Währung eines bestehenden Kontos** lässt sich nicht ändern — jeder
|
||||
gespeicherte Betrag würde danach etwas anderes bedeuten. Das Backend erlaubt
|
||||
es; die App nicht.
|
||||
|
||||
#### Fehlende Texte findet dieser Befehl
|
||||
|
||||
`String(localized:)` mit unbekanntem Schlüssel übersetzt anstandslos und zeigt
|
||||
dem Nutzer den Schlüssel. Vor dem Committen:
|
||||
|
||||
```
|
||||
cd ios && comm -23 \
|
||||
<(grep -rhoE 'localized: "[a-zA-Z0-9._]+"' --include="*.swift" . | sed 's/localized: "//; s/"//' | sort -u) \
|
||||
<(grep -oE '^"[a-zA-Z0-9._]+"' Texte/de.lproj/Localizable.strings | tr -d '"' | sort -u)
|
||||
```
|
||||
|
||||
Gibt er nichts aus, ist nichts offen. (Ein Schlüssel, der nur in einem
|
||||
`? :` steckt, taucht fälschlich als „fehlend" nicht auf — das ist die eine
|
||||
Lücke des Befehls.) Dass beide Sprachfassungen dieselben Schlüssel tragen,
|
||||
prüft ein `diff` der beiden `grep`-Ausgaben.
|
||||
|
||||
### Stufe 4 — Arbeitsliste ✅ (Commit `9edf610`)
|
||||
|
||||
Ein Reiter „Eingang" mit vier Abschnitten, jeder mit Anzahl im Kopf:
|
||||
offene Eingangseinträge, Übertragsvorschläge, Gebuchtes ohne Kategorie,
|
||||
Dubletten. `Arbeitsliste/ArbeitslisteAnsicht.swift`, dazu zwei schmale
|
||||
Auswahlblätter in `AuswahlBlaetter.swift`.
|
||||
|
||||
**Alles in einem Abruf** (`Arbeitsstand`), aus demselben Grund wie beim
|
||||
Dashboard: Vier getrennte Zwischenspeicher könnten offline aus vier
|
||||
verschiedenen Momenten stammen.
|
||||
|
||||
#### Der 409-Fall sitzt hier, und nur hier
|
||||
|
||||
`POST /inbox/{id}/transfer` kann mit 409 antworten. Das ist eine **Frage**,
|
||||
kein Fehler: Die App zeigt den Hinweis und wiederholt den Aufruf auf Wunsch
|
||||
mit `trotzdem: true`. `bucheAlsUebertrag` fängt `APIFehler.konflikt` eigens
|
||||
ab, statt ihn in den allgemeinen Fehlerweg laufen zu lassen. Wer später einen
|
||||
weiteren 409-Endpunkt anbindet, macht es genauso.
|
||||
|
||||
#### Kleinigkeiten, die man sonst zweimal findet
|
||||
|
||||
- **Ohne Konto lässt sich nichts buchen** (400 vom Backend). Der Knopf bleibt
|
||||
grau — und die Zeile sagt „ohne Konto", damit das nicht unerklärt wirkt.
|
||||
- **Ein Übertrag braucht keine Kategorie.** Daran erkennt man ihn:
|
||||
`transfer_account_id` ist gesetzt. Ohne diese Prüfung fragte die Liste ewig
|
||||
nach einer Kategorie, die es nie geben wird.
|
||||
- **Die Dublettenliste zeigt die Kennung der Bank.** Verschiedene Kennungen
|
||||
heissen: Die Bank hat sie ausdrücklich als verschiedene Zahlungen gemeldet.
|
||||
Ohne diese Spalte lässt sich die Frage nicht entscheiden.
|
||||
|
||||
#### Ein Backend-Fehler, den die App ans Licht brachte
|
||||
|
||||
`/uebertraege/vorschlaege` lieferte jedes Paar **zweimal**, einmal je
|
||||
Richtung: Was verknüpft wird, fällt über `verbraucht` heraus — ein blosser
|
||||
Vorschlag nicht. Die Weboberfläche zeigte den Doppeleintrag genauso.
|
||||
Behoben in `services/uebertraege.py` (`schlage_vor`), mit Test.
|
||||
|
||||
### Stufe 5 — Nebenräume ✅ (Commit `d290930`)
|
||||
|
||||
Kategorien, Schlagwörter, Bankabruf und der zweite Faktor — alle unter
|
||||
**Einstellungen**, nicht als eigene Reiter. Man braucht sie selten, und eine
|
||||
Leiste mit zehn Reitern findet niemand mehr. `Nebenraeume/`.
|
||||
|
||||
#### Was hier bewusst nicht geht
|
||||
|
||||
- **Den zweiten Faktor abschalten.** Es verlangt das Kontopasswort, und die
|
||||
App speichert keines. Ein Vollzugang auf diesem Gerät soll den Schutz des
|
||||
Kontos nicht aufheben können. Dasselbe gilt für neue Rettungscodes.
|
||||
- **Eine Bank verbinden.** Die Bank leitet fest auf `{base_url}/bank/rueckkehr`
|
||||
zurück; der Weg führt zwangsläufig über den Browser. Abrufen und Salden
|
||||
auffrischen gehen von hier.
|
||||
|
||||
#### Zwei Eigenheiten, die beim Nachbauen zählen
|
||||
|
||||
1. **Der zweite Faktor braucht auf dem Telefon keinen QR-Code.** Die
|
||||
Authenticator-App liegt daneben — `otpauth_uri` führt per Link direkt
|
||||
hinein. `qr_png` bleibt ungenutzt; auf dem iPad könnte man es zeigen.
|
||||
2. **Die Rettungscodes erscheinen genau einmal.** Das Blatt lässt sich nicht
|
||||
wegwischen (`interactiveDismissDisabled`) und erst schliessen, wenn der
|
||||
Nutzer bestätigt hat, sie gesichert zu haben. Wer es verliert, braucht
|
||||
das Kontopasswort am Rechner.
|
||||
|
||||
#### Einstellungen schreiben ohne Sichern-Knopf
|
||||
|
||||
`PUT /settings/{key}` nimmt je Aufruf **einen** Wert, als Query. Die Bindung
|
||||
schreibt beim Setzen direkt; ein Formular mit Sichern-Knopf wäre eine
|
||||
Zwischenstufe, die nichts bringt. Die Werte gehören dem **Konto**, nicht dem
|
||||
Gerät — das steht auch unter dem Abschnitt, sonst wundert sich jemand, warum
|
||||
der Browser sich mitändert.
|
||||
|
||||
#### Prüfdatei-Tests zählen keine Mengen mehr
|
||||
|
||||
Die Saat in `erzeuge_ios_fixture.py` wuchs um Eingangseintrag, Dubletten und
|
||||
ein Übertragspaar — und die älteren Tests wurden rot, weil sie exakte Anzahlen
|
||||
prüften. Sie prüfen jetzt Eigenschaften (`>= 2`, „enthält einen Übertrag"),
|
||||
damit sie das nächste Wachstum überleben. **Daran denken, wenn du die Saat
|
||||
erweiterst.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Was als Nächstes dran ist
|
||||
|
||||
**Stufe 6 ist dran.** Alle vier Backend-Ergänzungen sind drin; von hier an
|
||||
reicht die API, wie sie ist.
|
||||
|
||||
Was bewusst nicht mitkam: das **Logo** eines Vertrags (Upload und Zuschnitt,
|
||||
`POST /subscriptions/{id}/logo`), die **Reihenfolge** der Konten
|
||||
(`PUT /accounts/order`), das **Lösen** einer Übertrags-Verknüpfung
|
||||
(`DELETE /uebertraege/{link_id}`), die **Transfer-Schlagwörter**
|
||||
(`/categories/transfer-keywords`) und das **Anwenden** von Regeln auf
|
||||
Bestehendes (`POST /categories/keywords/anwenden`). Alles am Rechner
|
||||
bequemer, nichts davon hielt jemanden auf.
|
||||
|
||||
Weiter aussen vor, wie von Anfang an entschieden: Bank **verbinden**,
|
||||
Postfach einrichten, Sicherung und Wiederherstellung, Benutzerverwaltung.
|
||||
|
||||
**Stufe 6 — Erweiterungen.** Teilen-Menü und Kurzbefehle. Brauchen eigene Ziele
|
||||
im Xcode-Projekt, eine **App-Gruppe** und eine **geteilte
|
||||
Schlüsselbund-Gruppe**; `Netzwerk/Ablage.swift` hat dafür schon die beiden
|
||||
Konstanten `gruppe` und `schluesselbundGruppe` (stehen auf `nil`). Für die
|
||||
Kurzbefehle ist `POST /api/quick/booking` fertig: Header-Marke, idempotent über
|
||||
`external_id`, fertig formulierte Antwort-Meldung.
|
||||
|
||||
**Stufe 7 — Import, voll.** Erkennen, Rohansicht, Spaltenzuordnung,
|
||||
Formateinstellungen, Zeilenfilter, editierbare Prüfansicht, Saldo-Abgleich,
|
||||
Vorlagen, Verlauf mit Rücknahme.
|
||||
|
||||
### Zum Umfang, ehrlich
|
||||
|
||||
Die Weboberfläche hat rund 8900 Zeilen JSX. Nativ wird das nicht weniger.
|
||||
Stufe 2 hat rund 1800 Zeilen gebraucht, Stufe 3 rund 1200, Stufe 4 rund 900,
|
||||
Stufe 5 rund 1500; Stufe 6 ist überschaubar, Stufe 7 nochmal zwei bis drei
|
||||
Wochen.
|
||||
|
||||
Der **409-Fall** („nachfragen", mit `trotzdem=true` wiederholbar) ist in
|
||||
Stufe 4 angekommen und erledigt — siehe dort. In den Stufen 5 bis 7 kommt er
|
||||
nicht mehr vor.
|
||||
|
||||
Stufe 7 steht bewusst am Ende. Alles davor ist für sich nutzbar. Der Ablauf
|
||||
hat **keinen Server-Zustand** zwischen den Schritten: Die Datei wird bei jedem
|
||||
Schritt neu hochgeladen, und beim Buchen gehen die Vorschauzeilen unverändert
|
||||
zurück an den Server. Eine editierbare Prüftabelle mit 300 Zeilen und neun
|
||||
Spaltenzuordnungen ist auf Telefonbreite ein Entwurfsproblem, kein
|
||||
Übersetzungsproblem. Das Backend selbst sagt dazu in
|
||||
`backend/app/routers/quick.py`: *„Ein Auszug hat schnell hunderte Zeilen; die
|
||||
am Telefon durchzusehen wäre keine Erleichterung."* Der Nutzer kennt diesen
|
||||
Einwand und will den vollen Import trotzdem.
|
||||
|
||||
---
|
||||
|
||||
## 7. Fallen und Arbeitsweise
|
||||
|
||||
### Klartext-HTTP — der nächste Stolperstein
|
||||
|
||||
Das Projekt läuft mit der Voreinstellung von App Transport Security, also
|
||||
**nur https**. Ein selbstgehosteter Server im Heimnetz unter `http://…` wird
|
||||
abgewiesen, und `http://localhost` beim Entwickeln ebenso — obwohl
|
||||
`ServerAnsicht` http ausdrücklich zulässt und ein Test das festhält
|
||||
(„http bleibt http"). Wer gegen ein lokales Backend prüfen will, braucht
|
||||
in `project.yml` eine ATS-Ausnahme. Das ist eine Sicherheitsentscheidung des
|
||||
Nutzers: über Klartext geht die Gerätemarke, ein Vollzugang, mit jeder
|
||||
Anfrage mit.
|
||||
|
||||
### Was beim Übersetzen auffiel
|
||||
|
||||
Alles, was hier als verdächtig stand, hat gehalten: `Lader` als generische
|
||||
`@Observable`-Klasse, der `@MainActor`/Actor-Übergang zwischen `AppZustand`
|
||||
und `CertainClient`, `Tab` mit `.tabViewStyle(.sidebarAdaptable)`, Swift
|
||||
Testing statt XCTest. Eine Einschränkung: Das Projekt baut in **Sprachfassung
|
||||
Swift 5**. Unter Swift 6 greift die strikte Nebenläufigkeitsprüfung, und die
|
||||
Übergänge sind darauf nicht geschrieben — wer umstellen will, rechnet mit
|
||||
Arbeit an genau diesen Stellen.
|
||||
|
||||
### Schreibende Aufrufe mit Query statt Körper
|
||||
|
||||
Leicht zu übersehen, das Backend macht es an mehreren Stellen:
|
||||
`PUT /budget/template/category/{id}`, `PUT /budget/{label}/category/{id}`,
|
||||
`PUT /settings/{key}`, `POST /subscriptions/{id}/preis`.
|
||||
|
||||
### Konventionen des Projekts
|
||||
|
||||
- **Alles auf Deutsch**: Code-Kommentare, Bezeichner, Testnamen, Commit-
|
||||
Meldungen, Oberflächentexte. Testnamen sind deutsche Aussagesätze,
|
||||
ASCII-transliteriert (`test_app_marke_darf_lesen_und_schreiben`).
|
||||
- **Commits**: `typ: Wirkung für den Nutzer`, auf Deutsch, eine Wirkung je
|
||||
Commit. Fertige Arbeit wird committet und gepusht, ohne Rückfrage.
|
||||
- **Kommentare erklären das Warum**, nicht das Was — und zwar das, was der Code
|
||||
nicht zeigen kann. Das ganze Projekt ist so geschrieben; halte es durch.
|
||||
- **Neue Swift-Datei?** Danach `cd ios && xcodegen generate`. Die Ordner stehen
|
||||
einzeln in `project.yml`; ein neuer Ordner will dort eine Zeile.
|
||||
- **Tests**: `cd backend && .venv/bin/python -m pytest tests/ -q` (auf Windows
|
||||
`./.venv/Scripts/python.exe`). Python 3.12 im venv; das System-Python kann
|
||||
nicht bauen. Dauert hier rund 45 Sekunden.
|
||||
- **Erwartung: alles grün**, 438 Tests. Die zwei Ausfälle, die hier früher als
|
||||
„vorbekannt rot" standen, waren ein Windows-Problem der alten Maschine
|
||||
(`WinError 10106` im Subprozess) und laufen auf diesem Rechner durch. Wer
|
||||
einen roten Test sieht, hat ihn verursacht.
|
||||
- **Fehlt dem venv ein Modul** (`ModuleNotFoundError`), einmal
|
||||
`.venv/bin/python -m pip install -r requirements.txt` — es war hier nicht
|
||||
auf dem Stand von `requirements.txt`.
|
||||
- **Heredocs in Bash scheitern** an den deutschen Anführungszeichen („ ").
|
||||
Für Dateien mit solchen Zeichen das Write-Werkzeug nehmen, nicht `cat <<EOF`.
|
||||
|
||||
### Offene Punkte, die der Nutzer bestimmt
|
||||
|
||||
- Name, Bundle-ID und Signierung — er legt das Projekt an.
|
||||
- Ob die Benutzerverwaltung doch mit hinein soll.
|
||||
- Ob eine neu vergebene App-Marke am Rechner bestätigt werden muss, bevor sie
|
||||
gilt. Sicherer, aber umständlich beim Einrichten eines neuen Geräts.
|
||||
@@ -0,0 +1,95 @@
|
||||
# XcodeGen-Spezifikation - erzeugt das Xcode-Projekt aus diesen Quellen.
|
||||
# brew install xcodegen && cd ios && xcodegen generate && open Certain.xcodeproj
|
||||
# Die Datei ist die Wahrheit, nicht das erzeugte Certain.xcodeproj: wer einen
|
||||
# Ordner hinzufuegt, laeuft einmal "xcodegen generate" und ist fertig.
|
||||
name: Certain
|
||||
options:
|
||||
bundleIdPrefix: com.scarriffle
|
||||
# Ohne das baut Xcode eine englische App: Datumsauswahl und Monatsnamen
|
||||
# erscheinen dann als "10. Dec 2027" statt "10. Dez. 2027".
|
||||
developmentLanguage: de
|
||||
deploymentTarget:
|
||||
iOS: "26.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
targets:
|
||||
Certain:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Anmeldung
|
||||
- path: App
|
||||
- path: Abos
|
||||
- path: Arbeitsliste
|
||||
- path: Bausteine
|
||||
- path: Buchungen
|
||||
- path: Budget
|
||||
- path: Dashboard
|
||||
- path: Einstellungen
|
||||
- path: Konten
|
||||
- path: Modelle
|
||||
- path: Nebenraeume
|
||||
- path: Netzwerk
|
||||
# Deutsch und Englisch, zeilengleich gehalten.
|
||||
- path: Texte
|
||||
# Icon-Composer-Buendel (Ordner). Als Datei einhaengen, sonst zerlegt
|
||||
# XcodeGen es in eine Gruppe und actool findet das Icon nicht.
|
||||
- path: AppIcon.icon
|
||||
type: file
|
||||
buildPhase: resources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.certain
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
# Signing automatisch; wer mit einem anderen Apple-Konto baut, traegt
|
||||
# hier seine Team-ID ein (Xcode: Signing & Capabilities -> Team).
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: PP34X97WS3
|
||||
MARKETING_VERSION: "0.1"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
# Nur iPhone, nur Hochformat - am Rechner gibt es die Weboberflaeche.
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
SUPPORTED_PLATFORMS: "iphoneos iphonesimulator"
|
||||
SUPPORTS_MACCATALYST: NO
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.finance"
|
||||
# Sprachfassung 5: der Code ist nicht auf die strikte
|
||||
# Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen,
|
||||
# wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind.
|
||||
SWIFT_VERSION: "5.0"
|
||||
# Keine eigene Info.plist pflegen - Xcode erzeugt sie aus diesen Werten.
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Certain
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait"
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"
|
||||
# Ohne diesen Text stuerzt die App beim ersten Face-ID-Versuch ab.
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription: "Damit entsperrst du Certain, ohne deine PIN zu tippen."
|
||||
|
||||
CertainTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
# Tests/Fixtures/antworten.json landet als Ressource im Buendel -
|
||||
# der Test "Serverantworten" liest sie von dort.
|
||||
- path: Tests
|
||||
dependencies:
|
||||
- target: Certain
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
|
||||
schemes:
|
||||
Certain:
|
||||
build:
|
||||
targets:
|
||||
Certain: all
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- CertainTests
|
||||
@@ -187,7 +187,7 @@ export default function Categories() {
|
||||
editing,
|
||||
form: editing
|
||||
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
|
||||
: { name: "", kind: "expense", icon: "list", color: "#3f51b5" },
|
||||
: { name: "", kind: "expense", icon: "list", color: "#3f9c72" },
|
||||
});
|
||||
}
|
||||
function openCategory(groupId, editing = null) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useToast } from "../toast";
|
||||
//
|
||||
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
|
||||
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
|
||||
const STANDARDFARBE = "#3f51b5";
|
||||
const STANDARDFARBE = "#3f9c72";
|
||||
|
||||
export default function Tags() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
Reference in New Issue
Block a user