Compare commits
13
Commits
d04b6b74ad
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ec2dc5fd | ||
|
|
4e7f572d93 | ||
|
|
2d1d571b17 | ||
|
|
a079bf406c | ||
|
|
929a735699 | ||
|
|
9195906c2b | ||
|
|
f918297742 | ||
|
|
37517f360b | ||
|
|
d290930759 | ||
|
|
bab5cc5060 | ||
|
|
6c6257b8f9 | ||
|
|
9edf610d59 | ||
|
|
adbc3e0aa4 |
@@ -409,7 +409,7 @@ class CategoryGroup(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
|
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
|
||||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
@@ -431,7 +431,7 @@ class Category(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
|
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
|
||||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
@@ -1312,7 +1312,7 @@ class Tag(Base):
|
|||||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ class TagOut(BaseModel):
|
|||||||
class TagCreate(BaseModel):
|
class TagCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=60)
|
name: str = Field(min_length=1, max_length=60)
|
||||||
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
||||||
color: str = "#6b7480"
|
color: str = "#3f9c72"
|
||||||
|
|
||||||
|
|
||||||
class TagUpdate(BaseModel):
|
class TagUpdate(BaseModel):
|
||||||
@@ -406,7 +406,7 @@ class CategoryGroupCreate(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
kind: CategoryKind = CategoryKind.expense
|
kind: CategoryKind = CategoryKind.expense
|
||||||
icon: str = "list"
|
icon: str = "list"
|
||||||
color: str = "#6b7480"
|
color: str = "#3f9c72"
|
||||||
|
|
||||||
|
|
||||||
class CategoryGroupUpdate(BaseModel):
|
class CategoryGroupUpdate(BaseModel):
|
||||||
@@ -420,7 +420,7 @@ class CategoryCreate(BaseModel):
|
|||||||
group_id: int
|
group_id: int
|
||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
icon: str = "more-horizontal"
|
icon: str = "more-horizontal"
|
||||||
color: str = "#6b7480"
|
color: str = "#3f9c72"
|
||||||
excluded_from_stats: bool = False
|
excluded_from_stats: bool = False
|
||||||
spending_is_good: bool = False
|
spending_is_good: bool = False
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
|
|||||||
verbraucht: set[int] = set()
|
verbraucht: set[int] = set()
|
||||||
verknuepft = 0
|
verknuepft = 0
|
||||||
vorschlaege: list[tuple[Transaction, Transaction]] = []
|
vorschlaege: list[tuple[Transaction, Transaction]] = []
|
||||||
|
# Jedes Paar steht in ``kandidaten`` zweimal, einmal je Richtung. Was
|
||||||
|
# verknüpft wird, fällt über ``verbraucht`` heraus – ein Vorschlag aber
|
||||||
|
# nicht, und so kam dieselbe Zahlung zweimal zum Verbinden angeboten.
|
||||||
|
vorgeschlagen: set[tuple[int, int]] = set()
|
||||||
|
|
||||||
|
def schlage_vor(a: Transaction, b: Transaction) -> None:
|
||||||
|
paar = (min(a.id, b.id), max(a.id, b.id))
|
||||||
|
if paar in vorgeschlagen:
|
||||||
|
return
|
||||||
|
vorgeschlagen.add(paar)
|
||||||
|
vorschlaege.append((a, b))
|
||||||
|
|
||||||
for a_id in sorted(kandidaten):
|
for a_id in sorted(kandidaten):
|
||||||
if a_id in verbraucht:
|
if a_id in verbraucht:
|
||||||
@@ -321,17 +332,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict:
|
|||||||
gegen = _eindeutig(a, offen, kandidaten, verbraucht)
|
gegen = _eindeutig(a, offen, kandidaten, verbraucht)
|
||||||
if gegen is None:
|
if gegen is None:
|
||||||
if offen:
|
if offen:
|
||||||
vorschlaege.append((a, offen[0]))
|
schlage_vor(a, offen[0])
|
||||||
continue
|
continue
|
||||||
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
|
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
|
||||||
if a.category_id is not None or gegen.category_id is not None:
|
if a.category_id is not None or gegen.category_id is not None:
|
||||||
vorschlaege.append((a, gegen))
|
schlage_vor(a, gegen)
|
||||||
continue
|
continue
|
||||||
if not _hat_hinweis(a, gegen, bezeichner, typen):
|
if not _hat_hinweis(a, gegen, bezeichner, typen):
|
||||||
vorschlaege.append((a, gegen))
|
schlage_vor(a, gegen)
|
||||||
continue
|
continue
|
||||||
if nur_vorschlaege:
|
if nur_vorschlaege:
|
||||||
vorschlaege.append((a, gegen))
|
schlage_vor(a, gegen)
|
||||||
continue
|
continue
|
||||||
verbinde(db, user, a, gegen, automatisch=True)
|
verbinde(db, user, a, gegen, automatisch=True)
|
||||||
verbraucht.update((a.id, gegen.id))
|
verbraucht.update((a.id, gegen.id))
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from app.main import app # noqa: E402
|
|||||||
ZIEL = Path(__file__).resolve().parents[2] / "ios" / "Tests" / "Fixtures" / "antworten.json"
|
ZIEL = Path(__file__).resolve().parents[2] / "ios" / "Tests" / "Fixtures" / "antworten.json"
|
||||||
|
|
||||||
|
|
||||||
def _seed(c: TestClient) -> None:
|
def _seed(c: TestClient) -> dict:
|
||||||
"""Genug Daten, dass jedes Feld einmal belegt ist.
|
"""Genug Daten, dass jedes Feld einmal belegt ist.
|
||||||
|
|
||||||
Leere Listen pruefen nichts: Ein falscher Feldname in einer Zeile faellt
|
Leere Listen pruefen nichts: Ein falscher Feldname in einer Zeile faellt
|
||||||
@@ -91,24 +91,75 @@ def _seed(c: TestClient) -> None:
|
|||||||
params={"planned_cents": 50000})
|
params={"planned_cents": 50000})
|
||||||
c.post(f"/api/subscriptions/{abo['id']}/buchungen/{kauf['id']}")
|
c.post(f"/api/subscriptions/{abo['id']}/buchungen/{kauf['id']}")
|
||||||
|
|
||||||
|
# --- Arbeitsliste -----------------------------------------------------
|
||||||
|
# Ein Eingangseintrag entsteht nur ueber die Schnellerfassung; von Hand
|
||||||
|
# legt man keinen an. Also eine Marke ausstellen und damit buchen.
|
||||||
|
marke = c.post("/api/inbox/tokens",
|
||||||
|
json={"name": "Fixture", "default_account_id": giro["id"]}).json()
|
||||||
|
kopf = {"X-Certain-Token": marke["token"]}
|
||||||
|
c.post("/api/quick/booking", headers=kopf,
|
||||||
|
json={"amount": "12.90", "payee": "Baeckerei", "date": heute,
|
||||||
|
"external_id": "fixture-1"})
|
||||||
|
|
||||||
PFADE = {
|
# Zwei gleich aussehende Buchungen: gleiches Konto, gleicher Tag,
|
||||||
"version": "/api/version",
|
# gleicher krummer Betrag. Genau das meldet /dubletten.
|
||||||
"ichSelbst": "/api/auth/me",
|
for _ in range(2):
|
||||||
"einstellungen": "/api/settings",
|
c.post("/api/transactions", json={
|
||||||
"konten": "/api/accounts",
|
"type": "expense", "date": heute, "account_id": giro["id"],
|
||||||
"kategoriegruppen": "/api/categories",
|
"amount_cents": 1043, "payee": "Kiosk", "category_id": ausgabe["id"]})
|
||||||
"schlagwoerter": "/api/tags",
|
|
||||||
"buchungen": "/api/transactions",
|
# Ein Abgang und ein Zugang ueber verschiedene Konten, gleicher Betrag,
|
||||||
"uebertragsverknuepfungen": "/api/transactions/links",
|
# unverknuepft - daraus macht /uebertraege/vorschlaege einen Vorschlag.
|
||||||
"budget": "/api/budget/current",
|
# Beide Konten muessen dieselbe Waehrung haben: 75 CHF und 75 EUR sind
|
||||||
"budgetvorlage": "/api/budget/template",
|
# nicht derselbe Betrag, nur dieselbe Zahl (siehe _kandidaten).
|
||||||
"abos": "/api/subscriptions",
|
zweitgiro = c.post("/api/accounts", json={
|
||||||
"kontostaende": "/api/dashboard/balances",
|
"name": "Zweitkonto", "type": "girokonto", "currency": "EUR",
|
||||||
"budgetfortschritt": "/api/dashboard/budget-progress",
|
"opening_balance_cents": 0}).json()
|
||||||
"einnahmenAusgaben": "/api/dashboard/income-expense",
|
c.post("/api/transactions", json={
|
||||||
"letzteBuchungen": "/api/dashboard/recent",
|
"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:
|
def main() -> int:
|
||||||
@@ -118,10 +169,10 @@ def main() -> int:
|
|||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
print("Anmeldung fehlgeschlagen:", r.text)
|
print("Anmeldung fehlgeschlagen:", r.text)
|
||||||
return 1
|
return 1
|
||||||
_seed(c)
|
ids = _seed(c)
|
||||||
|
|
||||||
heraus = {}
|
heraus = {}
|
||||||
for name, pfad in PFADE.items():
|
for name, pfad in pfade(ids).items():
|
||||||
antwort = c.get(pfad)
|
antwort = c.get(pfad)
|
||||||
if antwort.status_code != 200:
|
if antwort.status_code != 200:
|
||||||
print(f"{pfad} antwortet {antwort.status_code}: {antwort.text[:200]}")
|
print(f"{pfad} antwortet {antwort.status_code}: {antwort.text[:200]}")
|
||||||
|
|||||||
@@ -354,3 +354,30 @@ def test_vorschlag_auch_wenn_die_gegenseite_tage_spaeter_steht(admin):
|
|||||||
# Bestaetigen kann der Nutzer es trotzdem.
|
# Bestaetigen kann der Nutzer es trotzdem.
|
||||||
assert admin.post("/api/uebertraege/verbinden",
|
assert admin.post("/api/uebertraege/verbinden",
|
||||||
json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200
|
json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_paar_wird_nur_einmal_vorgeschlagen(admin):
|
||||||
|
"""Jedes Paar steht intern zweimal, einmal je Richtung.
|
||||||
|
|
||||||
|
Ohne Entdopplung saehe der Nutzer dieselbe Zahlung zweimal zum Verbinden
|
||||||
|
angeboten - und wer das zweite anklickt, bekaeme den Fehler "bereits
|
||||||
|
verknuepft" fuer etwas, das er gerade selbst verknuepft hat.
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
betrag = 100000 + secrets.randbelow(800000)
|
||||||
|
a = admin.post("/api/accounts", json={
|
||||||
|
"name": f"A {secrets.token_hex(3)}", "currency": "EUR"}).json()
|
||||||
|
b = admin.post("/api/accounts", json={
|
||||||
|
"name": f"B {secrets.token_hex(3)}", "currency": "EUR"}).json()
|
||||||
|
raus = admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": "2026-07-14", "account_id": a["id"],
|
||||||
|
"amount_cents": betrag, "payee": "Umbuchung"}).json()
|
||||||
|
rein = admin.post("/api/transactions", json={
|
||||||
|
"type": "income", "date": "2026-07-14", "account_id": b["id"],
|
||||||
|
"amount_cents": betrag, "payee": "Umbuchung"}).json()
|
||||||
|
|
||||||
|
paare = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"]
|
||||||
|
meins = [p for p in paare
|
||||||
|
if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}]
|
||||||
|
assert len(meins) == 1, f"einmal vorschlagen, nicht {len(meins)}-mal"
|
||||||
|
|||||||
+8
-5
@@ -53,10 +53,13 @@ zweispaltig, offline der letzte Stand mit Datum.
|
|||||||
Stufe 1 ✅: Gerüst, Netzwerkschicht, Anmeldung samt zweitem Faktor und
|
Stufe 1 ✅: Gerüst, Netzwerkschicht, Anmeldung samt zweitem Faktor und
|
||||||
Pflicht-Passwortwechsel, Kontenliste. Stufe 2 ✅: Übersicht, Budget-Monat,
|
Pflicht-Passwortwechsel, Kontenliste. Stufe 2 ✅: Übersicht, Budget-Monat,
|
||||||
Buchungsliste mit Suche, Vertragsliste. Stufe 3 ✅: Buchungen, Budget-Zeilen,
|
Buchungsliste mit Suche, Vertragsliste. Stufe 3 ✅: Buchungen, Budget-Zeilen,
|
||||||
Verträge und Konten anlegen, ändern und löschen. Übersetzt fehlerfrei,
|
Verträge und Konten anlegen, ändern und löschen. Stufe 4 ✅: Arbeitsliste mit
|
||||||
49 Tests grün, läuft im Simulator; das Xcode-Projekt entsteht aus
|
Eingang, Übertragsvorschlägen, Gebuchtem ohne Kategorie und Dubletten.
|
||||||
`ios/project.yml`, die Modelle werden gegen echte Serverantworten geprüft.
|
Stufe 5 ✅: Kategorien samt Erkennungswörtern, Schlagwörter, Einstellungen,
|
||||||
Stufen 4–7 (Arbeitsliste, Nebenräume, Erweiterungen, voller CSV-Import)
|
Bankabruf und das Einrichten des zweiten Faktors.
|
||||||
stehen aus.
|
Übersetzt fehlerfrei, 59 Tests grün, läuft im Simulator; das Xcode-Projekt
|
||||||
|
entsteht aus `ios/project.yml`, die Modelle werden gegen echte
|
||||||
|
Serverantworten geprüft.
|
||||||
|
Stufen 6–7 (Teilen-Menü und Kurzbefehle, voller CSV-Import) stehen aus.
|
||||||
|
|
||||||
**Stand, Entscheidungen und offene Punkte: `ios/UEBERGABE.md`.**
|
**Stand, Entscheidungen und offene Punkte: `ios/UEBERGABE.md`.**
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import LocalAuthentication
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Der Sperrbildschirm. Face ID zuerst, die PIN als Rückfallweg – und als
|
||||||
|
/// einziger Weg, wenn das Gerät keine Biometrie hat oder sie abgelehnt wurde.
|
||||||
|
///
|
||||||
|
/// Die Sperre verdeckt die Oberfläche; die Gerätemarke bleibt im
|
||||||
|
/// Schlüsselbund wie zuvor (siehe `Ablage.hatPIN`). Nach fünf falschen PINs
|
||||||
|
/// meldet die App ab, sonst liesse sich eine sechsstellige PIN in Ruhe
|
||||||
|
/// durchprobieren.
|
||||||
|
struct SperrAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var pin = ""
|
||||||
|
@State private var meldung: String?
|
||||||
|
@State private var biometrieLaeuft = false
|
||||||
|
@FocusState private var imFeld: Bool
|
||||||
|
|
||||||
|
/// Face ID, Touch ID oder gar nichts – entscheidet Beschriftung und Symbol.
|
||||||
|
private var biometrie: LABiometryType {
|
||||||
|
let pruefer = LAContext()
|
||||||
|
guard pruefer.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
|
||||||
|
error: nil) else { return .none }
|
||||||
|
return pruefer.biometryType
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 28) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 44, weight: .medium))
|
||||||
|
.foregroundStyle(Color.marke)
|
||||||
|
|
||||||
|
Text(String(localized: "sperre.titel"))
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
SecureField(String(localized: "sperre.pin"), text: $pin)
|
||||||
|
.textContentType(.password)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.monospaced()
|
||||||
|
.font(.title3)
|
||||||
|
.focused($imFeld)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12,
|
||||||
|
style: .continuous))
|
||||||
|
.onChange(of: pin) { _, neu in
|
||||||
|
pin = String(neu.filter(\.isNumber).prefix(Ziffern.anzahl))
|
||||||
|
if pin.count == Ziffern.anzahl { pruefe() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let meldung {
|
||||||
|
Text(meldung)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
|
||||||
|
if biometrie != .none {
|
||||||
|
Button {
|
||||||
|
Task { await versucheBiometrie() }
|
||||||
|
} label: {
|
||||||
|
Label(biometrieName, systemImage: biometrieSymbol)
|
||||||
|
}
|
||||||
|
.disabled(biometrieLaeuft)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(String(localized: "sperre.abmelden"), role: .destructive) {
|
||||||
|
Task { await zustand.meldeAb() }
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.task { await versucheBiometrie() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var biometrieName: String {
|
||||||
|
biometrie == .touchID
|
||||||
|
? String(localized: "sperre.touchid")
|
||||||
|
: String(localized: "sperre.faceid")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var biometrieSymbol: String {
|
||||||
|
biometrie == .touchID ? "touchid" : "faceid"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fragt Face ID. Jede Ablehnung – abgebrochen, nicht erkannt, nicht
|
||||||
|
/// eingerichtet – führt schlicht zum PIN-Feld; das ist der Rückfallweg.
|
||||||
|
private func versucheBiometrie() async {
|
||||||
|
guard biometrie != .none, !biometrieLaeuft else {
|
||||||
|
imFeld = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
biometrieLaeuft = true
|
||||||
|
defer { biometrieLaeuft = false }
|
||||||
|
let pruefer = LAContext()
|
||||||
|
// Der Systemdialog bietet sonst „Passcode eingeben" an – das wäre der
|
||||||
|
// Gerätecode, nicht unsere PIN, und damit verwirrend.
|
||||||
|
pruefer.localizedFallbackTitle = ""
|
||||||
|
let geglueckt = (try? await pruefer.evaluatePolicy(
|
||||||
|
.deviceOwnerAuthenticationWithBiometrics,
|
||||||
|
localizedReason: String(localized: "sperre.grund"))) ?? false
|
||||||
|
if geglueckt {
|
||||||
|
zustand.entsperrt()
|
||||||
|
} else {
|
||||||
|
imFeld = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruefe() {
|
||||||
|
guard Ablage.stimmtPIN(pin) else {
|
||||||
|
pin = ""
|
||||||
|
Task {
|
||||||
|
let weiter = await zustand.falschePIN()
|
||||||
|
if weiter {
|
||||||
|
meldung = String(format: String(localized: "sperre.falsch"),
|
||||||
|
zustand.verbleibendeVersuche)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meldung = nil
|
||||||
|
zustand.entsperrt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Ziffern {
|
||||||
|
/// Sechs Stellen, wie der Gerätecode und wie der zweite Faktor.
|
||||||
|
static let anzahl = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PIN festlegen oder ändern. Beim ersten Mal führt kein Weg daran vorbei –
|
||||||
|
/// darum gibt es dann kein „Abbrechen".
|
||||||
|
struct PINEinrichtenAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@Environment(\.dismiss) private var schliesse
|
||||||
|
/// Beim ersten Einrichten Pflicht, beim Ändern aus den Einstellungen nicht.
|
||||||
|
var pflicht = true
|
||||||
|
|
||||||
|
@State private var neue = ""
|
||||||
|
@State private var wiederholung = ""
|
||||||
|
@State private var meldung: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
SecureField(String(localized: "pin.neu"), text: $neue)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.monospaced()
|
||||||
|
.onChange(of: neue) { _, wert in
|
||||||
|
neue = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl))
|
||||||
|
}
|
||||||
|
SecureField(String(localized: "pin.wiederholen"), text: $wiederholung)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.monospaced()
|
||||||
|
.onChange(of: wiederholung) { _, wert in
|
||||||
|
wiederholung = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl))
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
// Keine Überschrift: Die steht schon in der Leiste oben.
|
||||||
|
Text(String(localized: "pin.erklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let meldung {
|
||||||
|
Section {
|
||||||
|
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(String(localized: "pin.sichern"), action: lege)
|
||||||
|
.disabled(neue.count < Ziffern.anzahl
|
||||||
|
|| wiederholung.count < Ziffern.anzahl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "pin.einrichtenTitel"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
if !pflicht {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lege() {
|
||||||
|
guard neue.count == Ziffern.anzahl else {
|
||||||
|
meldung = String(localized: "pin.zuKurz"); return
|
||||||
|
}
|
||||||
|
guard neue == wiederholung else {
|
||||||
|
meldung = String(localized: "pin.ungleich")
|
||||||
|
wiederholung = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zustand.setzePIN(neue)
|
||||||
|
if !pflicht { schliesse() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,22 @@ final class AppZustand {
|
|||||||
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
|
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
|
||||||
private(set) var beschaeftigt = false
|
private(set) var beschaeftigt = false
|
||||||
|
|
||||||
|
// MARK: - Bildschirmsperre
|
||||||
|
|
||||||
|
/// Liegt der Sperrbildschirm über der App?
|
||||||
|
private(set) var gesperrt = false
|
||||||
|
/// Ob eine PIN eingerichtet ist. Als Eigenschaft und nicht direkt aus der
|
||||||
|
/// Ablage gelesen, damit die Ansicht nach dem Einrichten neu zeichnet.
|
||||||
|
private(set) var hatPIN = Ablage.hatPIN
|
||||||
|
private var seitWannWeg: Date?
|
||||||
|
private var fehlversuche = 0
|
||||||
|
|
||||||
|
/// So lange darf man weg sein, ohne erneut zu entsperren.
|
||||||
|
private static let gnadenfrist: TimeInterval = 60
|
||||||
|
/// Danach fliegt die Marke raus und es geht zur Anmeldung. Ohne Grenze
|
||||||
|
/// wäre eine sechsstellige PIN in Ruhe durchprobierbar.
|
||||||
|
private static let maxFehlversuche = 5
|
||||||
|
|
||||||
var serverAdresse: URL? { Ablage.serverAdresse }
|
var serverAdresse: URL? { Ablage.serverAdresse }
|
||||||
|
|
||||||
// MARK: - Start
|
// MARK: - Start
|
||||||
@@ -54,6 +70,9 @@ final class AppZustand {
|
|||||||
}
|
}
|
||||||
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
|
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
|
||||||
client = neuer
|
client = neuer
|
||||||
|
// Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm.
|
||||||
|
hatPIN = Ablage.hatPIN
|
||||||
|
gesperrt = hatPIN
|
||||||
guard Ablage.marke != nil else {
|
guard Ablage.marke != nil else {
|
||||||
lage = .brauchtAnmeldung
|
lage = .brauchtAnmeldung
|
||||||
return
|
return
|
||||||
@@ -237,6 +256,9 @@ final class AppZustand {
|
|||||||
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
|
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
|
||||||
Ablage.raeumeAuf()
|
Ablage.raeumeAuf()
|
||||||
Ablage.serverAdresse = adresse
|
Ablage.serverAdresse = adresse
|
||||||
|
hatPIN = false
|
||||||
|
gesperrt = false
|
||||||
|
fehlversuche = 0
|
||||||
if let adresse {
|
if let adresse {
|
||||||
client = CertainClient(basis: adresse)
|
client = CertainClient(basis: adresse)
|
||||||
lage = .brauchtAnmeldung
|
lage = .brauchtAnmeldung
|
||||||
@@ -246,6 +268,48 @@ final class AppZustand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sperren und entsperren
|
||||||
|
|
||||||
|
/// iOS fotografiert die App beim Wegschalten für den App-Umschalter.
|
||||||
|
/// Darum wird sofort gesperrt und nicht erst beim Zurückkommen – sonst
|
||||||
|
/// stünden die Kontostände im Umschalter.
|
||||||
|
func gehtWeg() {
|
||||||
|
guard hatPIN, !gesperrt else { return }
|
||||||
|
seitWannWeg = Date()
|
||||||
|
gesperrt = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kurz weggewischt – etwa für eine eintreffende Nachricht – geht ohne
|
||||||
|
/// Nachfrage wieder auf. Erst nach der Gnadenfrist wird gefragt.
|
||||||
|
func kommtZurueck() {
|
||||||
|
guard let weg = seitWannWeg else { return }
|
||||||
|
seitWannWeg = nil
|
||||||
|
if Date().timeIntervalSince(weg) <= Self.gnadenfrist { gesperrt = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
func entsperrt() {
|
||||||
|
gesperrt = false
|
||||||
|
fehlversuche = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet einen Fehlversuch. Gibt zurück, ob noch Versuche übrig sind;
|
||||||
|
/// beim letzten meldet die App ab.
|
||||||
|
func falschePIN() async -> Bool {
|
||||||
|
fehlversuche += 1
|
||||||
|
guard fehlversuche >= Self.maxFehlversuche else { return true }
|
||||||
|
await meldeAb()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var verbleibendeVersuche: Int { max(0, Self.maxFehlversuche - fehlversuche) }
|
||||||
|
|
||||||
|
func setzePIN(_ pin: String) {
|
||||||
|
Ablage.setzePIN(pin)
|
||||||
|
hatPIN = true
|
||||||
|
gesperrt = false
|
||||||
|
fehlversuche = 0
|
||||||
|
}
|
||||||
|
|
||||||
func aktualisiereEinstellungen() async {
|
func aktualisiereEinstellungen() async {
|
||||||
guard let client else { return }
|
guard let client else { return }
|
||||||
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct CertainApp: App {
|
struct CertainApp: App {
|
||||||
@State private var zustand = AppZustand()
|
@State private var zustand = AppZustand()
|
||||||
|
@Environment(\.scenePhase) private var phase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
|
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
|
||||||
@@ -19,6 +20,19 @@ struct CertainApp: App {
|
|||||||
.environment(zustand)
|
.environment(zustand)
|
||||||
.task { await zustand.starte() }
|
.task { await zustand.starte() }
|
||||||
.preferredColorScheme(schema)
|
.preferredColorScheme(schema)
|
||||||
|
// Einmal hier, damit jeder Knopf, Schalter und Link
|
||||||
|
// die Markenfarbe traegt statt des System-Blaus.
|
||||||
|
.tint(.marke)
|
||||||
|
.onChange(of: phase) { _, neue in
|
||||||
|
// `inactive` kommt, bevor iOS das Bild für den
|
||||||
|
// App-Umschalter macht. Dort soll der Sperrbildschirm
|
||||||
|
// stehen, nicht der Kontostand.
|
||||||
|
switch neue {
|
||||||
|
case .inactive, .background: zustand.gehtWeg()
|
||||||
|
case .active: zustand.kommtZurueck()
|
||||||
|
@unknown default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,15 @@ struct RootAnsicht: View {
|
|||||||
ZweitenFaktorEinrichtenHinweis()
|
ZweitenFaktorEinrichtenHinweis()
|
||||||
|
|
||||||
case .bereit:
|
case .bereit:
|
||||||
Hauptnavigation()
|
// 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,6 +59,9 @@ struct Hauptnavigation: View {
|
|||||||
Tab(String(localized: "reiter.buchungen"), systemImage: "list.bullet.rectangle") {
|
Tab(String(localized: "reiter.buchungen"), systemImage: "list.bullet.rectangle") {
|
||||||
NavigationStack { BuchungsListe() }
|
NavigationStack { BuchungsListe() }
|
||||||
}
|
}
|
||||||
|
Tab(String(localized: "reiter.arbeitsliste"), systemImage: "tray") {
|
||||||
|
NavigationStack { ArbeitslisteAnsicht() }
|
||||||
|
}
|
||||||
Tab(String(localized: "reiter.abos"), systemImage: "arrow.triangle.2.circlepath") {
|
Tab(String(localized: "reiter.abos"), systemImage: "arrow.triangle.2.circlepath") {
|
||||||
NavigationStack { AboListe() }
|
NavigationStack { AboListe() }
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"fill-specializations" : [
|
||||||
|
{
|
||||||
|
"value" : {
|
||||||
|
"automatic-gradient" : "display-p3:0.35897,0.51845,0.35368,1.00000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : {
|
||||||
|
"automatic-gradient" : "display-p3:0.16927,0.24447,0.16678,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"solid" : "display-p3:0.82502,0.96783,0.89028,1.00000"
|
||||||
|
},
|
||||||
|
"image-name" : "Marke.svg",
|
||||||
|
"name" : "Marke"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Alles, worüber jemand entscheiden muss, in einem Abruf.
|
||||||
|
///
|
||||||
|
/// Vier Quellen, die nichts voneinander wissen – aber einen gemeinsamen
|
||||||
|
/// Stand ergeben sollen. Vier getrennte Zwischenspeicher könnten offline aus
|
||||||
|
/// vier verschiedenen Momenten stammen.
|
||||||
|
struct Arbeitsstand: Codable, Sendable, Hashable {
|
||||||
|
let eingang: [Eingangseintrag]
|
||||||
|
let vorschlaege: Uebertragsvorschlaege
|
||||||
|
let dubletten: Dublettengruppen
|
||||||
|
/// Gebuchtes ohne Kategorie – der stillste der vier Posten. Ohne
|
||||||
|
/// Kategorie zählt eine Ausgabe in keiner Budget-Zeile mit.
|
||||||
|
let ohneKategorie: [Buchung]
|
||||||
|
|
||||||
|
var istLeer: Bool {
|
||||||
|
offenerEingang.isEmpty && vorschlaege.vorschlaege.isEmpty
|
||||||
|
&& dubletten.gruppen.isEmpty && ohneKategorie.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was im Eingang noch eine Entscheidung braucht. Gebuchtes ist erledigt
|
||||||
|
/// und steht nur im Weg.
|
||||||
|
var offenerEingang: [Eingangseintrag] {
|
||||||
|
eingang.filter { $0.status == .pending || $0.status == .unclear }
|
||||||
|
}
|
||||||
|
|
||||||
|
var anzahlOffen: Int {
|
||||||
|
offenerEingang.count + vorschlaege.vorschlaege.count
|
||||||
|
+ dubletten.gruppen.count + ohneKategorie.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CertainClient {
|
||||||
|
func arbeitsstand() async throws -> Arbeitsstand {
|
||||||
|
async let eingang = self.eingang()
|
||||||
|
async let vorschlaege = uebertragsvorschlaege()
|
||||||
|
async let dubletten = self.dubletten()
|
||||||
|
// Das Backend lässt Überträge hier schon aussen vor und überspringt
|
||||||
|
// verknüpfte Buchungen – siehe den uncategorized-Zweig im Router.
|
||||||
|
async let ohneKategorie = buchungen(ohneKategorie: true, grenze: 100)
|
||||||
|
return try await Arbeitsstand(eingang: eingang, vorschlaege: vorschlaege,
|
||||||
|
dubletten: dubletten, ohneKategorie: ohneKategorie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Arbeitsliste: eine Zeile, eine Entscheidung.
|
||||||
|
///
|
||||||
|
/// Fachlich der Teil, der am besten aufs Telefon gehört – man arbeitet ihn
|
||||||
|
/// zwischendurch ab, nicht am Schreibtisch.
|
||||||
|
struct ArbeitslisteAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var lader: Lader<Arbeitsstand>?
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
|
@State private var kategorieFuer: Eingangseintrag?
|
||||||
|
@State private var kategorieFuerBuchung: Buchung?
|
||||||
|
@State private var uebertragFuer: Eingangseintrag?
|
||||||
|
@State private var rueckfrage: Rueckfrage?
|
||||||
|
|
||||||
|
/// Was das Backend mit 409 beantwortet hat, samt dem Aufruf, der sich
|
||||||
|
/// mit `trotzdem` wiederholen lässt.
|
||||||
|
struct Rueckfrage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let text: String
|
||||||
|
let nochmal: () async -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let lader {
|
||||||
|
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in
|
||||||
|
liste(stand)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "reiter.arbeitsliste"))
|
||||||
|
.task {
|
||||||
|
guard lader == nil, let client = zustand.client else { return }
|
||||||
|
let neuer = Lader<Arbeitsstand>(schluessel: "arbeitsliste") {
|
||||||
|
try await client.arbeitsstand()
|
||||||
|
}
|
||||||
|
lader = neuer
|
||||||
|
await neuer.lade()
|
||||||
|
}
|
||||||
|
.sheet(item: $kategorieFuer) { eintrag in
|
||||||
|
KategorieWaehlen(titel: eintrag.payee ?? eintrag.quelle,
|
||||||
|
nachschlag: zustand.nachschlag) { kategorie in
|
||||||
|
await tu { try await $0.setzeEingangsKategorie(eintrag.id, kategorie: kategorie) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $kategorieFuerBuchung) { buchung in
|
||||||
|
KategorieWaehlen(titel: buchung.ueberschrift,
|
||||||
|
nachschlag: zustand.nachschlag,
|
||||||
|
art: buchung.type == .income ? .income : .expense) { kategorie in
|
||||||
|
await tu { try await $0.aendereBuchung(buchung.id,
|
||||||
|
BuchungAendern(categoryId: kategorie)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $uebertragFuer) { eintrag in
|
||||||
|
ZielkontoWaehlen(nachschlag: zustand.nachschlag, ausser: eintrag.accountId) { ziel in
|
||||||
|
await bucheAlsUebertrag(eintrag, ziel: ziel, trotzdem: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(item: $rueckfrage) { frage in
|
||||||
|
Alert(title: Text(String(localized: "arbeit.rueckfrage")),
|
||||||
|
message: Text(frage.text),
|
||||||
|
primaryButton: .default(Text(String(localized: "arbeit.trotzdem"))) {
|
||||||
|
Task { await frage.nochmal() }
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel(Text(String(localized: "allgemein.abbrechen"))))
|
||||||
|
}
|
||||||
|
.alert(String(localized: "fehler.titel"),
|
||||||
|
isPresented: .constant(vorgang.fehler != nil)) {
|
||||||
|
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
|
||||||
|
} message: {
|
||||||
|
Text(vorgang.fehler ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Liste
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func liste(_ stand: Arbeitsstand) -> some View {
|
||||||
|
if stand.istLeer {
|
||||||
|
LeerAnsicht(titel: String(localized: "arbeit.leerTitel"),
|
||||||
|
erklaerung: String(localized: "arbeit.leerErklaerung"),
|
||||||
|
symbol: "checkmark.circle")
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
if !stand.offenerEingang.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(stand.offenerEingang) { eintrag in
|
||||||
|
EingangsZeile(eintrag: eintrag, nachschlag: zustand.nachschlag,
|
||||||
|
buchen: { Task { await buche(eintrag) } },
|
||||||
|
uebertrag: { uebertragFuer = eintrag },
|
||||||
|
verwerfen: { Task { await verwirf(eintrag) } })
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Kopf(titel: String(localized: "arbeit.eingang"),
|
||||||
|
anzahl: stand.offenerEingang.count)
|
||||||
|
} footer: {
|
||||||
|
if stand.offenerEingang.contains(where: \.laesstSichBuchen) {
|
||||||
|
Button(String(localized: "arbeit.alleBuchen")) {
|
||||||
|
Task { await tu { _ = try await $0.bucheGanzenEingang() } }
|
||||||
|
}
|
||||||
|
.disabled(vorgang.laeuft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stand.vorschlaege.vorschlaege.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(stand.vorschlaege.vorschlaege) { paar in
|
||||||
|
VorschlagsZeile(paar: paar,
|
||||||
|
verbinden: { Task { await verbinde(paar) } },
|
||||||
|
ablehnen: { Task { await lehneAb(paar) } })
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Kopf(titel: String(localized: "arbeit.uebertraege"),
|
||||||
|
anzahl: stand.vorschlaege.vorschlaege.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stand.ohneKategorie.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(stand.ohneKategorie) { buchung in
|
||||||
|
Button {
|
||||||
|
kategorieFuerBuchung = buchung
|
||||||
|
} label: {
|
||||||
|
OhneKategorieZeile(buchung: buchung,
|
||||||
|
nachschlag: zustand.nachschlag)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Kopf(titel: String(localized: "arbeit.ohneKategorie"),
|
||||||
|
anzahl: stand.ohneKategorie.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stand.dubletten.gruppen.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(stand.dubletten.gruppen) { gruppe in
|
||||||
|
DublettenZeile(gruppe: gruppe, nachschlag: zustand.nachschlag) {
|
||||||
|
Task { await behalte(gruppe) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Kopf(titel: String(localized: "arbeit.dubletten"),
|
||||||
|
anzahl: stand.dubletten.gruppen.count)
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "arbeit.dublettenErklaerung"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable { await lader?.aktualisiere() }
|
||||||
|
.disabled(vorgang.laeuft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entscheidungen
|
||||||
|
|
||||||
|
/// Jede Entscheidung läuft gleich: schreiben, dann neu laden. Die Liste
|
||||||
|
/// muss danach stimmen – ein erledigter Posten, der stehen bleibt, führt
|
||||||
|
/// zum zweiten Klick auf dieselbe Sache.
|
||||||
|
private func tu(_ arbeit: @escaping (CertainClient) async throws -> Void) async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
if await vorgang.fuehreAus({ try await arbeit(client) }) {
|
||||||
|
await lader?.aktualisiere()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buche(_ eintrag: Eingangseintrag) async {
|
||||||
|
await tu { _ = try await $0.bucheEingang(eintrag.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verwirf(_ eintrag: Eingangseintrag) async {
|
||||||
|
await tu { try await $0.verwerfeEingang(eintrag.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der einzige 409-Fall der App: Das Backend hat Bedenken und lässt sich
|
||||||
|
/// mit `trotzdem` überstimmen. Den Hinweis zu verschlucken wäre falsch,
|
||||||
|
/// aber ihn als blossen Fehler zu zeigen auch – er ist eine Frage.
|
||||||
|
private func bucheAlsUebertrag(_ eintrag: Eingangseintrag, ziel: Int,
|
||||||
|
trotzdem: Bool) async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
do {
|
||||||
|
_ = try await client.bucheEingangAlsUebertrag(eintrag.id, aufKonto: ziel,
|
||||||
|
trotzdem: trotzdem)
|
||||||
|
await lader?.aktualisiere()
|
||||||
|
} catch APIFehler.konflikt(let text) {
|
||||||
|
rueckfrage = Rueckfrage(text: text) {
|
||||||
|
await bucheAlsUebertrag(eintrag, ziel: ziel, trotzdem: true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
vorgang.fehler = (error as? APIFehler)?.errorDescription
|
||||||
|
?? error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verbinde(_ paar: Uebertragsvorschlaege.Vorschlag) async {
|
||||||
|
await tu { try await $0.verbindeUebertrag(paar.a.id, paar.b.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lehneAb(_ paar: Uebertragsvorschlaege.Vorschlag) async {
|
||||||
|
await tu { try await $0.lehneUebertragAb(paar.a.id, paar.b.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func behalte(_ gruppe: Dublettengruppen.Gruppe) async {
|
||||||
|
await tu { try await $0.behalteDubletten(gruppe.zeilen.map(\.id)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bausteine
|
||||||
|
|
||||||
|
private struct Kopf: View {
|
||||||
|
let titel: String
|
||||||
|
let anzahl: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(titel)
|
||||||
|
Spacer()
|
||||||
|
Text("\(anzahl)")
|
||||||
|
.monospacedDigit()
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(.quaternary, in: Capsule())
|
||||||
|
}
|
||||||
|
.textCase(nil)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EingangsZeile: View {
|
||||||
|
let eintrag: Eingangseintrag
|
||||||
|
let nachschlag: Nachschlag
|
||||||
|
let buchen: () -> Void
|
||||||
|
let uebertrag: () -> Void
|
||||||
|
let verwerfen: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(eintrag.payee ?? String(localized: "buchung.ohneBezeichnung"))
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(unterzeile)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(eintrag.betrag.formatiert())
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.foregroundStyle(eintrag.amountCents < 0 ? Color.primary : Color.green)
|
||||||
|
}
|
||||||
|
|
||||||
|
if eintrag.status == .unclear {
|
||||||
|
// Die Erkennung war unsicher. Das gehört vor die Knöpfe, nicht
|
||||||
|
// dahinter – sonst bucht man es weg, ohne es gelesen zu haben.
|
||||||
|
Label(String(localized: "arbeit.unklarHinweis"),
|
||||||
|
systemImage: "questionmark.circle")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(String(localized: "arbeit.buchen"), action: buchen)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(!eintrag.laesstSichBuchen)
|
||||||
|
Button(String(localized: "arbeit.alsUebertrag"), action: uebertrag)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
Spacer()
|
||||||
|
Button(String(localized: "arbeit.verwerfen"), role: .destructive,
|
||||||
|
action: verwerfen)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unterzeile: String {
|
||||||
|
var teile = [eintrag.quelle, eintrag.date.formatiert()]
|
||||||
|
if let konto = nachschlag.konto(eintrag.accountId) {
|
||||||
|
teile.append(konto.name)
|
||||||
|
} else {
|
||||||
|
// Ohne Konto lässt es sich nicht buchen. Das soll dastehen,
|
||||||
|
// statt dass der Knopf unerklärt grau bleibt.
|
||||||
|
teile.append(String(localized: "arbeit.ohneKonto"))
|
||||||
|
}
|
||||||
|
return teile.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct VorschlagsZeile: View {
|
||||||
|
let paar: Uebertragsvorschlaege.Vorschlag
|
||||||
|
let verbinden: () -> Void
|
||||||
|
let ablehnen: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
seite(paar.a)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "arrow.down")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(paar.a.date.formatiert())
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
seite(paar.b)
|
||||||
|
|
||||||
|
if paar.hatKategorie {
|
||||||
|
// Eine zugeordnete Buchung fällt durchs Verbinden aus dem
|
||||||
|
// Budget. Das soll niemanden überraschen.
|
||||||
|
Label(String(localized: "arbeit.hatKategorie"),
|
||||||
|
systemImage: "exclamationmark.circle")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(String(localized: "arbeit.verbinden"), action: verbinden)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
Button(String(localized: "arbeit.keinUebertrag"), action: ablehnen)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seite(_ seite: Uebertragsvorschlaege.Seite) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: seite.type.symbol)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(seite.type == .income ? .green : .secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(seite.accountName)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(seite.bezeichnung)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(seite.betrag.formatiert())
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OhneKategorieZeile: View {
|
||||||
|
let buchung: Buchung
|
||||||
|
let nachschlag: Nachschlag
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Symbolplakette(symbol: "more-horizontal", farbe: "#6b7480", groesse: 32)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(buchung.ueberschrift)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(buchung.date.formatiert()) · \(nachschlag.kontoname(buchung.accountId))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(buchung.betrag.formatiert(vorzeichen: .ohne))
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DublettenZeile: View {
|
||||||
|
let gruppe: Dublettengruppen.Gruppe
|
||||||
|
let nachschlag: Nachschlag
|
||||||
|
let behalten: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text(Geldbetrag(cents: gruppe.amountCents,
|
||||||
|
waehrung: waehrung).formatiert())
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
Text("· \(gruppe.zeilen.count)×")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(gruppe.datum.formatiert()) · \(nachschlag.kontoname(gruppe.accountId))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(gruppe.zeilen) { zeile in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("•").foregroundStyle(.tertiary)
|
||||||
|
Text(zeile.bezeichnung)
|
||||||
|
.lineLimit(1)
|
||||||
|
if let kennung = zeile.kennung {
|
||||||
|
// Verschiedene Kennungen heissen: Die Bank hat sie
|
||||||
|
// ausdrücklich als verschiedene Zahlungen gemeldet.
|
||||||
|
Text(kennung)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(zeile.herkunft)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(String(localized: "arbeit.istRichtigSo"), action: behalten)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var waehrung: String {
|
||||||
|
nachschlag.konto(gruppe.accountId)?.currency ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Eine Kategorie zuordnen – mehr nicht.
|
||||||
|
///
|
||||||
|
/// Ein eigenes Blatt statt des ganzen Buchungsformulars: In der Arbeitsliste
|
||||||
|
/// fehlt genau diese eine Angabe, und ein Formular mit zwölf Feldern davor
|
||||||
|
/// macht aus einer Entscheidung eine Sitzung.
|
||||||
|
struct KategorieWaehlen: View {
|
||||||
|
let titel: String
|
||||||
|
let nachschlag: Nachschlag
|
||||||
|
var art: Kategorieart = .expense
|
||||||
|
/// Wird mit der gewählten ID gerufen; schliesst danach von selbst.
|
||||||
|
let waehle: (Int) async -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var schliesse
|
||||||
|
@State private var laeuft = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(gruppen) { gruppe in
|
||||||
|
Section(gruppe.name) {
|
||||||
|
ForEach(gruppe.categories.filter { !$0.isArchived }) { kategorie in
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
laeuft = true
|
||||||
|
await waehle(kategorie.id)
|
||||||
|
schliesse()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Symbolplakette(symbol: kategorie.icon,
|
||||||
|
farbe: kategorie.color, groesse: 30)
|
||||||
|
Text(kategorie.name)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(laeuft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(titel)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gruppen: [Kategoriegruppe] {
|
||||||
|
nachschlag.gruppen
|
||||||
|
.filter { $0.kind == art }
|
||||||
|
.filter { $0.categories.contains { !$0.isArchived } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auf welches Konto der Übertrag gehen soll.
|
||||||
|
struct ZielkontoWaehlen: View {
|
||||||
|
let nachschlag: Nachschlag
|
||||||
|
/// Das Konto, von dem er kommt – es kann nicht zugleich das Ziel sein.
|
||||||
|
var ausser: Int?
|
||||||
|
let waehle: (Int) async -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var schliesse
|
||||||
|
@State private var laeuft = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(auswaehlbar) { konto in
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
laeuft = true
|
||||||
|
await waehle(konto.id)
|
||||||
|
schliesse()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Symbolplakette(symbol: konto.icon, farbe: konto.color,
|
||||||
|
groesse: 30)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(konto.name)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text(konto.type.name)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(laeuft)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "arbeit.zielkontoErklaerung"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "buchung.zielkonto"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var auswaehlbar: [Konto] {
|
||||||
|
nachschlag.konten.filter { !$0.isArchived && $0.id != ausser }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,27 @@ extension Color {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// Die Markenfarbe: dasselbe Grün wie `--accent` in `web/src/styles.css`,
|
||||||
|
/// damit App, Weboberfläche und Appicon eine Familie sind. In Dunkel eine
|
||||||
|
/// Spur heller, sonst säuft das Grün auf dunklem Grund ab.
|
||||||
|
static let marke = Color(uiColor: UIColor { zug in
|
||||||
|
zug.userInterfaceStyle == .dark
|
||||||
|
? UIColor(red: 0.373, green: 0.741, blue: 0.569, alpha: 1) // #5fbd91
|
||||||
|
: UIColor(red: 0.247, green: 0.612, blue: 0.447, alpha: 1) // #3f9c72
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Farbauswahl für Kategorien, Schlagwörter und Konten – eine Liste für
|
||||||
|
/// alle drei Formulare, damit sie nicht auseinanderlaufen. Das Markengrün
|
||||||
|
/// steht vorn und ist die Vorbelegung; der Rest bleibt bunt, sonst liessen
|
||||||
|
/// sich die Kategorien in Diagrammen nicht auseinanderhalten.
|
||||||
|
enum Farbwahl {
|
||||||
|
static let standard = "#3f9c72"
|
||||||
|
static let palette = [standard, "#3f51b5", "#b3541e", "#8e24aa",
|
||||||
|
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
|
||||||
|
}
|
||||||
|
|
||||||
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
|
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
|
||||||
///
|
///
|
||||||
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
|
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
|
||||||
|
|||||||
@@ -183,11 +183,6 @@ struct SymbolUndFarbeFeld: View {
|
|||||||
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
|
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
|
||||||
]
|
]
|
||||||
|
|
||||||
private static let farben = [
|
|
||||||
"#3f51b5", "#2e7d55", "#b3541e", "#8e24aa", "#c62828",
|
|
||||||
"#0277bd", "#6b7480", "#5d4037",
|
|
||||||
]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
|
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
|
||||||
@@ -196,7 +191,7 @@ struct SymbolUndFarbeFeld: View {
|
|||||||
.foregroundStyle(Color(hex: farbe))
|
.foregroundStyle(Color(hex: farbe))
|
||||||
}
|
}
|
||||||
|
|
||||||
auswahl(Self.farben, aktuell: farbe) { farbe = $0 } inhalt: { wert in
|
auswahl(Farbwahl.palette, aktuell: farbe) { farbe = $0 } inhalt: { wert in
|
||||||
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
|
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ struct BudgetAnsicht: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: "budget.vorlage"), systemImage: "doc.text")
|
Label(String(localized: "budget.vorlage"), systemImage: "doc.text")
|
||||||
}
|
}
|
||||||
.tint(.indigo)
|
.tint(.marke)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
|
|||||||
@@ -9,12 +9,16 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */; };
|
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */; };
|
||||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4119B85EFF3FB38E84FAA913 /* Konto.swift */; };
|
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4119B85EFF3FB38E84FAA913 /* Konto.swift */; };
|
||||||
|
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */; };
|
||||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
||||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
|
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
|
||||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */; };
|
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */; };
|
||||||
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */; };
|
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FED25B5220CEAD1638B53F /* BuchungFormular.swift */; };
|
||||||
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; };
|
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776F26865B849D2318B6819B /* Zustandsansichten.swift */; };
|
||||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
|
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
|
||||||
|
2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */; };
|
||||||
|
2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */; };
|
||||||
|
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */; };
|
||||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
||||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
||||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
|
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */; };
|
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */; };
|
||||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; };
|
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; };
|
||||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
|
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
|
||||||
|
5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */; };
|
||||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */; };
|
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */; };
|
||||||
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */ = {isa = PBXBuildFile; fileRef = 3137E525D1ADE3DB2F8E45A8 /* antworten.json */; };
|
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */ = {isa = PBXBuildFile; fileRef = 3137E525D1ADE3DB2F8E45A8 /* antworten.json */; };
|
||||||
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */; };
|
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */; };
|
||||||
@@ -30,15 +35,20 @@
|
|||||||
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51DE5BDB909910D586F819F /* Budget.swift */; };
|
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51DE5BDB909910D586F819F /* Budget.swift */; };
|
||||||
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */; };
|
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */; };
|
||||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8BE152C9F91584900796ADA /* Grundmodelle.swift */; };
|
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8BE152C9F91584900796ADA /* Grundmodelle.swift */; };
|
||||||
|
90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F14B825905BA99472E4B289 /* BankAnsicht.swift */; };
|
||||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; };
|
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; };
|
||||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; };
|
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; };
|
||||||
|
A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = C841BA87849B2A461449BAF5 /* KategorieFormular.swift */; };
|
||||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; };
|
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; };
|
||||||
|
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */; };
|
||||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
|
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
|
||||||
|
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = D42C757A9D818A524C4CAC8A /* AppIcon.icon */; };
|
||||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
||||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
||||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; };
|
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; };
|
||||||
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 778040646D05B1DEEE3BF727 /* Formular.swift */; };
|
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 778040646D05B1DEEE3BF727 /* Formular.swift */; };
|
||||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; };
|
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; };
|
||||||
|
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */; };
|
||||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; };
|
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; };
|
||||||
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */; };
|
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */; };
|
||||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
||||||
@@ -46,6 +56,7 @@
|
|||||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; };
|
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; };
|
||||||
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; };
|
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; };
|
||||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; };
|
F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; };
|
||||||
|
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -61,27 +72,35 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
|
09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EinstellungenAnsicht.swift; sourceTree = "<group>"; };
|
||||||
09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnsicht.swift; sourceTree = "<group>"; };
|
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>"; };
|
0FDF13E61F770748435D0097 /* BudgetZeileBearbeiten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetZeileBearbeiten.swift; sourceTree = "<group>"; };
|
||||||
13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; };
|
13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; };
|
||||||
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; };
|
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; };
|
||||||
1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
1F14B825905BA99472E4B289 /* BankAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BankAnsicht.swift; sourceTree = "<group>"; };
|
||||||
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrundlagenTests.swift; sourceTree = "<group>"; };
|
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrundlagenTests.swift; sourceTree = "<group>"; };
|
||||||
287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = "<group>"; };
|
287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = "<group>"; };
|
||||||
|
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nebenraeume.swift; sourceTree = "<group>"; };
|
||||||
3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; };
|
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>"; };
|
327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; };
|
||||||
|
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SperrAnsicht.swift; sourceTree = "<group>"; };
|
||||||
|
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = "<group>"; };
|
||||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
|
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
|
||||||
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = "<group>"; };
|
451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = "<group>"; };
|
||||||
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = "<group>"; };
|
4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = "<group>"; };
|
||||||
|
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArbeitslisteAnsicht.swift; sourceTree = "<group>"; };
|
||||||
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswortWechselAnsicht.swift; sourceTree = "<group>"; };
|
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswortWechselAnsicht.swift; sourceTree = "<group>"; };
|
||||||
5D3341CDA026CDF55D621A8A /* CertainTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CertainTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
5D3341CDA026CDF55D621A8A /* CertainTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CertainTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5D6CEAB46FB8955115A3064B /* Endpunkte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpunkte.swift; sourceTree = "<group>"; };
|
5D6CEAB46FB8955115A3064B /* Endpunkte.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpunkte.swift; sourceTree = "<group>"; };
|
||||||
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eingaben.swift; sourceTree = "<group>"; };
|
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eingaben.swift; sourceTree = "<group>"; };
|
||||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zeitangaben.swift; sourceTree = "<group>"; };
|
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zeitangaben.swift; sourceTree = "<group>"; };
|
||||||
776F26865B849D2318B6819B /* Zustandsansichten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zustandsansichten.swift; sourceTree = "<group>"; };
|
776F26865B849D2318B6819B /* Zustandsansichten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zustandsansichten.swift; sourceTree = "<group>"; };
|
||||||
|
777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorienAnsicht.swift; sourceTree = "<group>"; };
|
||||||
778040646D05B1DEEE3BF727 /* Formular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formular.swift; sourceTree = "<group>"; };
|
778040646D05B1DEEE3BF727 /* Formular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formular.swift; sourceTree = "<group>"; };
|
||||||
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = "<group>"; };
|
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = "<group>"; };
|
||||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = "<group>"; };
|
78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = "<group>"; };
|
||||||
|
89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsAnsicht.swift; sourceTree = "<group>"; };
|
||||||
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = "<group>"; };
|
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = "<group>"; };
|
||||||
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboListe.swift; sourceTree = "<group>"; };
|
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>"; };
|
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetAnsicht.swift; sourceTree = "<group>"; };
|
||||||
@@ -94,6 +113,9 @@
|
|||||||
C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zwischenspeicher.swift; sourceTree = "<group>"; };
|
C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zwischenspeicher.swift; sourceTree = "<group>"; };
|
||||||
C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIFehler.swift; sourceTree = "<group>"; };
|
C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIFehler.swift; sourceTree = "<group>"; };
|
||||||
C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorieFormular.swift; sourceTree = "<group>"; };
|
||||||
|
D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiterFaktorAnsicht.swift; sourceTree = "<group>"; };
|
||||||
|
D42C757A9D818A524C4CAC8A /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||||
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = "<group>"; };
|
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = "<group>"; };
|
||||||
DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = "<group>"; };
|
DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = "<group>"; };
|
||||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; };
|
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; };
|
||||||
@@ -161,6 +183,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E8CBA42A030509615199DD45 /* Abo.swift */,
|
E8CBA42A030509615199DD45 /* Abo.swift */,
|
||||||
|
0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */,
|
||||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
327787E4869ED7D0C7E9EE63 /* Buchung.swift */,
|
||||||
E51DE5BDB909910D586F819F /* Budget.swift */,
|
E51DE5BDB909910D586F819F /* Budget.swift */,
|
||||||
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */,
|
||||||
@@ -168,6 +191,7 @@
|
|||||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
||||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
||||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
||||||
|
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */,
|
||||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
||||||
);
|
);
|
||||||
path = Modelle;
|
path = Modelle;
|
||||||
@@ -187,6 +211,7 @@
|
|||||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
|
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
|
||||||
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
|
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
|
||||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
|
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
|
||||||
|
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */,
|
||||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
|
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
|
||||||
);
|
);
|
||||||
path = Anmeldung;
|
path = Anmeldung;
|
||||||
@@ -195,9 +220,11 @@
|
|||||||
BC82A6A323BDD2AD8325BEA2 = {
|
BC82A6A323BDD2AD8325BEA2 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D42C757A9D818A524C4CAC8A /* AppIcon.icon */,
|
||||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
|
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
|
||||||
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
||||||
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
||||||
|
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */,
|
||||||
5E720D91F0CF9DBDA975A959 /* Bausteine */,
|
5E720D91F0CF9DBDA975A959 /* Bausteine */,
|
||||||
D99101183ED9DA14C8D22722 /* Buchungen */,
|
D99101183ED9DA14C8D22722 /* Buchungen */,
|
||||||
C4B3E718A814E0236A12EB86 /* Budget */,
|
C4B3E718A814E0236A12EB86 /* Budget */,
|
||||||
@@ -205,6 +232,7 @@
|
|||||||
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
||||||
298E43BBA9248E5A9779DA98 /* Konten */,
|
298E43BBA9248E5A9779DA98 /* Konten */,
|
||||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
||||||
|
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */,
|
||||||
DB51F54CC077E85C286AD9DC /* Netzwerk */,
|
DB51F54CC077E85C286AD9DC /* Netzwerk */,
|
||||||
524FD85EDE7C4DE5ABE9A8E5 /* Tests */,
|
524FD85EDE7C4DE5ABE9A8E5 /* Tests */,
|
||||||
0EDDD922C06B1465AD13C782 /* Texte */,
|
0EDDD922C06B1465AD13C782 /* Texte */,
|
||||||
@@ -271,6 +299,27 @@
|
|||||||
path = Netzwerk;
|
path = Netzwerk;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1F14B825905BA99472E4B289 /* BankAnsicht.swift */,
|
||||||
|
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */,
|
||||||
|
777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */,
|
||||||
|
89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */,
|
||||||
|
D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */,
|
||||||
|
);
|
||||||
|
path = Nebenraeume;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */,
|
||||||
|
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */,
|
||||||
|
);
|
||||||
|
path = Arbeitsliste;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -352,6 +401,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */,
|
||||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
|
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -386,6 +436,10 @@
|
|||||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
|
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */,
|
||||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */,
|
||||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */,
|
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */,
|
||||||
|
2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */,
|
||||||
|
2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */,
|
||||||
|
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */,
|
||||||
|
90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */,
|
||||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
|
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
|
||||||
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */,
|
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */,
|
||||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
|
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
|
||||||
@@ -404,16 +458,22 @@
|
|||||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
||||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
||||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
|
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
|
||||||
|
A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */,
|
||||||
|
5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */,
|
||||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
||||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
||||||
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */,
|
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */,
|
||||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
||||||
|
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */,
|
||||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
|
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
|
||||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
|
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
|
||||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */,
|
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */,
|
||||||
|
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */,
|
||||||
|
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */,
|
||||||
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */,
|
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */,
|
||||||
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */,
|
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */,
|
||||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */,
|
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */,
|
||||||
|
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */,
|
||||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */,
|
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -451,9 +511,12 @@
|
|||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Certain;
|
INFOPLIST_KEY_CFBundleDisplayName = Certain;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||||
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -461,8 +524,12 @@
|
|||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -631,9 +698,12 @@
|
|||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Certain;
|
INFOPLIST_KEY_CFBundleDisplayName = Certain;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||||
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -641,8 +711,12 @@
|
|||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Stufe 1 zeigt hier nur, woran man ist, und lässt abmelden. Die echten
|
/// Einstellungen und die Nebenräume: Kategorien, Schlagwörter, Bank,
|
||||||
/// Einstellungen (Hauptwährung, Budget-Starttag, Sprache, Gerätemarken)
|
/// zweiter Faktor.
|
||||||
/// kommen in Stufe 5.
|
///
|
||||||
|
/// Sie hängen hier und nicht als eigene Reiter: Man braucht sie selten, und
|
||||||
|
/// eine Leiste mit zehn Reitern findet niemand mehr.
|
||||||
struct EinstellungenAnsicht: View {
|
struct EinstellungenAnsicht: View {
|
||||||
@Environment(AppZustand.self) private var zustand
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
@State private var zeigeAbmeldefrage = false
|
@State private var zeigeAbmeldefrage = false
|
||||||
|
@State private var markierte: [MarkiertesWort] = []
|
||||||
|
@State private var aenderePIN = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -18,17 +23,83 @@ struct EinstellungenAnsicht: View {
|
|||||||
LabeledContent(String(localized: "einstellungen.server"),
|
LabeledContent(String(localized: "einstellungen.server"),
|
||||||
value: adresse.host() ?? adresse.absoluteString)
|
value: adresse.host() ?? adresse.absoluteString)
|
||||||
}
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ZweiterFaktorAnsicht()
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
aenderePIN = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "pin.aendern"), systemImage: "lock.rotation")
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: "einstellungen.zugang"))
|
Text(String(localized: "einstellungen.zugang"))
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(String(localized: "einstellungen.markeErklaerung"))
|
Text(String(localized: "einstellungen.markeErklaerung"))
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(String(localized: "einstellungen.ausDemServer")) {
|
Section(String(localized: "nebenraeume.titel")) {
|
||||||
LabeledContent(String(localized: "einstellungen.hauptwaehrung"),
|
NavigationLink {
|
||||||
value: zustand.einstellungen.basiswaehrung)
|
KategorienAnsicht()
|
||||||
LabeledContent(String(localized: "einstellungen.budgetStarttag"),
|
} label: {
|
||||||
value: "\(zustand.einstellungen.budgetStarttag)")
|
Label(String(localized: "nebenraeume.kategorien"), systemImage: "square.grid.2x2")
|
||||||
|
}
|
||||||
|
NavigationLink {
|
||||||
|
TagsAnsicht()
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "nebenraeume.tags"), systemImage: "tag")
|
||||||
|
}
|
||||||
|
NavigationLink {
|
||||||
|
BankAnsicht()
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "nebenraeume.bank"), systemImage: "building.columns")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !markierte.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(markierte) { wort in
|
||||||
|
MarkiertesWortZeile(wort: wort,
|
||||||
|
behalten: { Task { await behalte(wort) } },
|
||||||
|
entfernen: { Task { await entferne(wort) } })
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: "kategorien.markiertTitel"))
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "kategorien.markiertErklaerung"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker(String(localized: "einstellungen.erscheinungsbild"),
|
||||||
|
selection: bindung(\.erscheinungsbild, "theme")) {
|
||||||
|
Text(String(localized: "einstellungen.system")).tag("system")
|
||||||
|
Text(String(localized: "einstellungen.hell")).tag("light")
|
||||||
|
Text(String(localized: "einstellungen.dunkel")).tag("dark")
|
||||||
|
}
|
||||||
|
Picker(String(localized: "einstellungen.hauptwaehrung"),
|
||||||
|
selection: bindung(\.basiswaehrung, "base_currency")) {
|
||||||
|
ForEach(zustand.einstellungen.aktiveWaehrungen, id: \.self) { code in
|
||||||
|
Text(code).tag(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker(String(localized: "einstellungen.budgetStarttag"),
|
||||||
|
selection: tagBindung) {
|
||||||
|
ForEach(1...28, id: \.self) { tag in
|
||||||
|
Text("\(tag).").tag(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: "einstellungen.ausDemServer"))
|
||||||
|
} footer: {
|
||||||
|
// Die Hauptwährung rechnet alle Auswertungen um. Das gilt
|
||||||
|
// für jedes Gerät, nicht nur für dieses.
|
||||||
|
Text(String(localized: "einstellungen.serverweitErklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fehler = vorgang.fehler {
|
||||||
|
Section { FehlerZeile(text: fehler) }
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -39,8 +110,12 @@ struct EinstellungenAnsicht: View {
|
|||||||
Text(String(localized: "einstellungen.abmeldenErklaerung"))
|
Text(String(localized: "einstellungen.abmeldenErklaerung"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $aenderePIN) {
|
||||||
|
PINEinrichtenAnsicht(pflicht: false)
|
||||||
|
}
|
||||||
.navigationTitle(String(localized: "reiter.einstellungen"))
|
.navigationTitle(String(localized: "reiter.einstellungen"))
|
||||||
.refreshable { await zustand.aktualisiereEinstellungen() }
|
.refreshable { await lade() }
|
||||||
|
.task { await lade() }
|
||||||
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
|
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
|
||||||
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
|
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
|
||||||
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||||
@@ -49,4 +124,87 @@ struct EinstellungenAnsicht: View {
|
|||||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Einstellungen schreiben
|
||||||
|
|
||||||
|
/// Eine Bindung, die beim Setzen gleich zum Server geht.
|
||||||
|
///
|
||||||
|
/// Ein „Sichern"-Knopf wäre hier falsch: Es ist je Zeile genau ein Wert,
|
||||||
|
/// und das Backend nimmt ihn einzeln entgegen (`PUT /settings/{key}`).
|
||||||
|
private func bindung(_ pfad: KeyPath<Einstellungen, String>,
|
||||||
|
_ schluessel: String) -> Binding<String> {
|
||||||
|
Binding(get: { zustand.einstellungen[keyPath: pfad] },
|
||||||
|
set: { neu in Task { await setze(schluessel, neu) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tagBindung: Binding<Int> {
|
||||||
|
Binding(get: { zustand.einstellungen.budgetStarttag },
|
||||||
|
set: { neu in Task { await setze("budget_start_day", String(neu)) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setze(_ schluessel: String, _ wert: String) async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
let geglueckt = await vorgang.fuehreAus {
|
||||||
|
_ = try await client.setzeEinstellung(schluessel, wert)
|
||||||
|
}
|
||||||
|
if geglueckt { await zustand.aktualisiereEinstellungen() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Markierte Erkennungswörter
|
||||||
|
|
||||||
|
private func lade() async {
|
||||||
|
await zustand.aktualisiereEinstellungen()
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
markierte = (try? await client.markierteErkennungswoerter()) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func behalte(_ wort: MarkiertesWort) async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
if await vorgang.fuehreAus({ try await client.bestaetigeErkennungswort(wort.id) }) {
|
||||||
|
await lade()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func entferne(_ wort: MarkiertesWort) async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) {
|
||||||
|
await lade()
|
||||||
|
await zustand.ladeNachschlag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Erkennungswort, das oft wieder korrigiert wird.
|
||||||
|
private struct MarkiertesWortZeile: View {
|
||||||
|
let wort: MarkiertesWort
|
||||||
|
let behalten: () -> Void
|
||||||
|
let entfernen: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text(wort.word)
|
||||||
|
.font(.callout)
|
||||||
|
Spacer()
|
||||||
|
Text(wort.categoryName)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(String(format: String(localized: "kategorien.markiertZaehler"),
|
||||||
|
wort.appliedCount, wort.overriddenCount))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(String(localized: "kategorien.behalten"), action: behalten)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
Button(String(localized: "kategorien.entfernen"), role: .destructive,
|
||||||
|
action: entfernen)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,215 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `InboxItemOut` – eine von aussen erfasste Zahlung, bevor der Kontoauszug
|
||||||
|
/// sie bestätigt.
|
||||||
|
///
|
||||||
|
/// Quellen: `shortcut` (Kurzbefehl vom Telefon), `mail` (Postfach-Abruf),
|
||||||
|
/// `bank`. Der Eintrag bleibt auch nach dem Buchen stehen, damit der Abgleich
|
||||||
|
/// dieselbe Zahlung später wiedererkennt.
|
||||||
|
struct Eingangseintrag: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let source: String
|
||||||
|
let status: Eingangsstand
|
||||||
|
let accountId: Int?
|
||||||
|
let date: Tagesdatum
|
||||||
|
/// Negativ bei einer Ausgabe – anders als an einer Buchung, wo die
|
||||||
|
/// Richtung in `type` steckt.
|
||||||
|
let amountCents: Int
|
||||||
|
let currency: String
|
||||||
|
let payee: String?
|
||||||
|
let note: String?
|
||||||
|
let categoryId: Int?
|
||||||
|
let transactionId: Int?
|
||||||
|
/// Das jeweils **andere** Konto, wenn die Buchung ein Übertrag ist. Ohne
|
||||||
|
/// das liesse sich ein Übertrag nicht von einer Ausgabe unterscheiden,
|
||||||
|
/// und die App fragte weiter nach einer Kategorie.
|
||||||
|
let transferAccountId: Int?
|
||||||
|
let fxRate: Double?
|
||||||
|
let counterAmountCents: Int?
|
||||||
|
let counterCurrency: String?
|
||||||
|
let createdAt: Zeitpunkt?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, source, status, date, currency, payee, note
|
||||||
|
case accountId = "account_id"
|
||||||
|
case amountCents = "amount_cents"
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case transactionId = "transaction_id"
|
||||||
|
case transferAccountId = "transfer_account_id"
|
||||||
|
case fxRate = "fx_rate"
|
||||||
|
case counterAmountCents = "counter_amount_cents"
|
||||||
|
case counterCurrency = "counter_currency"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
|
||||||
|
|
||||||
|
var istUebertrag: Bool { transferAccountId != nil }
|
||||||
|
|
||||||
|
/// Buchen geht nur mit Konto – das Backend lehnt es sonst mit 400 ab.
|
||||||
|
var laesstSichBuchen: Bool {
|
||||||
|
accountId != nil && (status == .pending || status == .unclear)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fehlt nach dem Buchen noch die Kategorie? Überträge brauchen keine.
|
||||||
|
var brauchtKategorie: Bool {
|
||||||
|
status == .booked && categoryId == nil && !istUebertrag
|
||||||
|
}
|
||||||
|
|
||||||
|
var quelle: String {
|
||||||
|
switch source {
|
||||||
|
case "shortcut": return String(localized: "eingang.quelle.kurzbefehl")
|
||||||
|
case "mail": return String(localized: "eingang.quelle.postfach")
|
||||||
|
case "bank": return String(localized: "eingang.quelle.bank")
|
||||||
|
default: return source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `InboxStatus`
|
||||||
|
enum Eingangsstand: String, Codable, Sendable {
|
||||||
|
/// Als Buchung übernommen.
|
||||||
|
case booked
|
||||||
|
/// Wartet auf Bestätigung.
|
||||||
|
case pending
|
||||||
|
/// Die Erkennung war unsicher – erst ansehen.
|
||||||
|
case unclear
|
||||||
|
/// Verworfen. Taucht in der Liste nicht mehr auf.
|
||||||
|
case discarded
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
switch self {
|
||||||
|
case .booked: return String(localized: "eingang.stand.gebucht")
|
||||||
|
case .pending: return String(localized: "eingang.stand.offen")
|
||||||
|
case .unclear: return String(localized: "eingang.stand.unklar")
|
||||||
|
case .discarded: return String(localized: "eingang.stand.verworfen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/uebertraege/vorschlaege`
|
||||||
|
///
|
||||||
|
/// Zwei getrennte Buchungen, die zusammen ein Übertrag sein könnten. Das
|
||||||
|
/// Backend entscheidet hier **nichts** – beim Hinsehen darf sich nichts ändern.
|
||||||
|
struct Uebertragsvorschlaege: Codable, Sendable, Hashable {
|
||||||
|
/// Wie viele beim Suchen von selbst verknüpft wurden. Beim blossen
|
||||||
|
/// Fragen immer null.
|
||||||
|
let verknuepft: Int
|
||||||
|
let vorschlaege: [Vorschlag]
|
||||||
|
|
||||||
|
struct Vorschlag: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let a: Seite
|
||||||
|
let b: Seite
|
||||||
|
|
||||||
|
/// Die Reihenfolge ist nicht bedeutsam – die ID ist es deshalb auch
|
||||||
|
/// nur als Paar.
|
||||||
|
var id: String { "\(min(a.id, b.id))-\(max(a.id, b.id))" }
|
||||||
|
|
||||||
|
/// Eine zugeordnete Buchung fällt durchs Verbinden aus dem Budget.
|
||||||
|
/// Das soll niemanden überraschen.
|
||||||
|
var hatKategorie: Bool { a.categoryId != nil || b.categoryId != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Seite: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let date: Tagesdatum
|
||||||
|
let type: Buchungsart
|
||||||
|
let amountCents: Int
|
||||||
|
let currency: String
|
||||||
|
let accountId: Int
|
||||||
|
let accountName: String
|
||||||
|
let payee: String?
|
||||||
|
let description: String?
|
||||||
|
let categoryId: Int?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, date, type, currency, payee, description
|
||||||
|
case amountCents = "amount_cents"
|
||||||
|
case accountId = "account_id"
|
||||||
|
case accountName = "account_name"
|
||||||
|
case categoryId = "category_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
|
||||||
|
var bezeichnung: String {
|
||||||
|
for text in [payee, description] where !(text ?? "").isEmpty { return text! }
|
||||||
|
return String(localized: "buchung.ohneBezeichnung")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/transactions/dubletten`
|
||||||
|
///
|
||||||
|
/// Buchungen, die gleich aussehen. Entschieden wird hier nichts: Drei
|
||||||
|
/// Aufrundungen über 1.00 am selben Tag sind echt, drei gleiche Einkäufe
|
||||||
|
/// können es auch sein – das weiss nur der Mensch.
|
||||||
|
struct Dublettengruppen: Codable, Sendable, Hashable {
|
||||||
|
let gruppen: [Gruppe]
|
||||||
|
|
||||||
|
struct Gruppe: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let accountId: Int
|
||||||
|
let datum: Tagesdatum
|
||||||
|
let amountCents: Int
|
||||||
|
let typ: Buchungsart
|
||||||
|
let zeilen: [Zeile]
|
||||||
|
|
||||||
|
var id: String { "\(accountId)-\(datum.text)-\(amountCents)-\(typ.rawValue)" }
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case datum, typ, zeilen
|
||||||
|
case accountId = "account_id"
|
||||||
|
case amountCents = "amount_cents"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Zeile: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let payee: String?
|
||||||
|
let description: String?
|
||||||
|
let categoryId: Int?
|
||||||
|
let herkunft: String
|
||||||
|
/// Die Kennung der Bank. Tragen zwei Zeilen verschiedene, hat die
|
||||||
|
/// Bank sie ausdrücklich als verschiedene Zahlungen gemeldet.
|
||||||
|
let kennung: String?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, payee, description, herkunft, kennung
|
||||||
|
case categoryId = "category_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
var bezeichnung: String {
|
||||||
|
for text in [payee, description] where !(text ?? "").isEmpty { return text! }
|
||||||
|
return String(localized: "buchung.ohneBezeichnung")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwei Buchungen, die zusammengehören – für Verbinden und Ablehnen.
|
||||||
|
struct Buchungspaar: Encodable, Sendable {
|
||||||
|
let aId: Int
|
||||||
|
let bId: Int
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case aId = "a_id"
|
||||||
|
case bId = "b_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/inbox/{id}/transfer`
|
||||||
|
struct AlsUebertragBuchen: Encodable, Sendable {
|
||||||
|
let toAccountId: Int
|
||||||
|
/// Nur setzen, wenn der Nutzer es nach einer Rückfrage ausdrücklich will.
|
||||||
|
/// Das Backend antwortet mit 409, wenn es Bedenken hat.
|
||||||
|
var trotzdem = false
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case trotzdem
|
||||||
|
case toAccountId = "to_account_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was `POST /api/inbox/book-all` zurückmeldet.
|
||||||
|
struct SammelErgebnis: Codable, Sendable, Hashable {
|
||||||
|
let gebucht: Int
|
||||||
|
let uebertraege: Int?
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Schlagwörter
|
||||||
|
|
||||||
|
/// `KeywordOut` – ein Wort, an dem die Erkennung eine Kategorie festmacht.
|
||||||
|
///
|
||||||
|
/// Nicht zu verwechseln mit `Schlagwort` (einem Tag): Ein Tag klebt an einer
|
||||||
|
/// Buchung, ein Schlagwort hängt an einer **Kategorie** und wirkt nur auf
|
||||||
|
/// künftige Importe.
|
||||||
|
struct Erkennungswort: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let categoryId: Int
|
||||||
|
let word: String
|
||||||
|
/// Nur wenn der Betrag mitentscheiden soll – „Migros" über 200 ist
|
||||||
|
/// Haushalt, darunter Lebensmittel.
|
||||||
|
let amountCents: Int?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, word
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case amountCents = "amount_cents"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `FlaggedKeywordOut` – ein Wort, das oft wieder korrigiert wird.
|
||||||
|
///
|
||||||
|
/// Ab fünf Anwendungen und mindestens einem Drittel Korrekturen meldet das
|
||||||
|
/// Backend es zur Prüfung. Behalten oder entfernen entscheidet der Mensch.
|
||||||
|
struct MarkiertesWort: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let categoryId: Int?
|
||||||
|
let categoryName: String
|
||||||
|
let word: String
|
||||||
|
let appliedCount: Int
|
||||||
|
let overriddenCount: Int
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, word
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case categoryName = "category_name"
|
||||||
|
case appliedCount = "applied_count"
|
||||||
|
case overriddenCount = "overridden_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `KeywordCreate`
|
||||||
|
struct ErkennungswortAnlegen: Encodable, Sendable {
|
||||||
|
let word: String
|
||||||
|
var amountCents: Int?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case word
|
||||||
|
case amountCents = "amount_cents"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kategorien schreiben
|
||||||
|
|
||||||
|
/// `CategoryCreate`
|
||||||
|
struct KategorieAnlegen: Encodable, Sendable {
|
||||||
|
let groupId: Int
|
||||||
|
let name: String
|
||||||
|
var icon = "more-horizontal"
|
||||||
|
var color = "#6b7480"
|
||||||
|
/// Zählt in keiner Auswertung mit – etwa durchlaufende Posten.
|
||||||
|
var excludedFromStats = false
|
||||||
|
/// Eine Ausgabe, die kein Verlust ist: Sparen.
|
||||||
|
var spendingIsGood = false
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case name, icon, color
|
||||||
|
case groupId = "group_id"
|
||||||
|
case excludedFromStats = "excluded_from_stats"
|
||||||
|
case spendingIsGood = "spending_is_good"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CategoryUpdate` – nur die gesetzten Felder gehen mit.
|
||||||
|
struct KategorieAendern: Encodable, Sendable {
|
||||||
|
var name: String?
|
||||||
|
var groupId: Int?
|
||||||
|
var icon: String?
|
||||||
|
var color: String?
|
||||||
|
var isArchived: Bool?
|
||||||
|
var excludedFromStats: Bool?
|
||||||
|
var spendingIsGood: Bool?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case name, icon, color
|
||||||
|
case groupId = "group_id"
|
||||||
|
case isArchived = "is_archived"
|
||||||
|
case excludedFromStats = "excluded_from_stats"
|
||||||
|
case spendingIsGood = "spending_is_good"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CategoryGroupCreate`
|
||||||
|
struct GruppeAnlegen: Encodable, Sendable {
|
||||||
|
let name: String
|
||||||
|
var kind: Kategorieart = .expense
|
||||||
|
var icon = "list"
|
||||||
|
var color = "#6b7480"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CategoryGroupUpdate`
|
||||||
|
struct GruppeAendern: Encodable, Sendable {
|
||||||
|
var name: String?
|
||||||
|
var icon: String?
|
||||||
|
var color: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tags schreiben
|
||||||
|
|
||||||
|
/// `TagCreate`
|
||||||
|
struct TagAnlegen: Encodable, Sendable {
|
||||||
|
let name: String
|
||||||
|
var color = "#6b7480"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TagUpdate`
|
||||||
|
struct TagAendern: Encodable, Sendable {
|
||||||
|
var name: String?
|
||||||
|
var color: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Zweiter Faktor
|
||||||
|
|
||||||
|
/// `TwoFactorStatusOut`
|
||||||
|
struct ZweiterFaktorStand: Codable, Sendable, Hashable {
|
||||||
|
let enabled: Bool
|
||||||
|
let recoveryCodesLeft: Int
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case enabled
|
||||||
|
case recoveryCodesLeft = "recovery_codes_left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TwoFactorSetupOut` – das noch **nicht** aktive Geheimnis.
|
||||||
|
///
|
||||||
|
/// Gültig wird es erst, wenn ein Code daraus geprüft wurde. Wer die
|
||||||
|
/// Einrichtung abbricht, lässt einfach ein unbenutztes Geheimnis stehen.
|
||||||
|
struct ZweiterFaktorEinrichtung: Codable, Sendable, Hashable {
|
||||||
|
let secret: String
|
||||||
|
let otpauthUri: String
|
||||||
|
/// Der QR-Code als `data:`-URI. Auf dem Telefon selten gebraucht – dort
|
||||||
|
/// führt der Weg über `otpauthUri` direkt in die Authenticator-App.
|
||||||
|
let qrPng: String
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case secret
|
||||||
|
case otpauthUri = "otpauth_uri"
|
||||||
|
case qrPng = "qr_png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RecoveryCodesOut` – einmal zu sehen, danach nie wieder.
|
||||||
|
struct Rettungscodes: Codable, Sendable, Hashable {
|
||||||
|
let codes: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ZweiterFaktorCode: Encodable, Sendable {
|
||||||
|
let code: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bank
|
||||||
|
|
||||||
|
/// `BankConnectionOut` – eine bestehende Anbindung.
|
||||||
|
///
|
||||||
|
/// Hergestellt wird sie am Rechner: Die Bank leitet fest auf
|
||||||
|
/// `{base_url}/bank/rueckkehr` zurück. Abrufen geht von hier.
|
||||||
|
struct Bankverbindung: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let aspspName: String
|
||||||
|
let aspspCountry: String
|
||||||
|
let validUntil: Zeitpunkt?
|
||||||
|
let lastSyncAt: Zeitpunkt?
|
||||||
|
/// Takt des automatischen Abrufs in Minuten, 0 = aus.
|
||||||
|
let pollMinutes: Int
|
||||||
|
let lastError: String?
|
||||||
|
let expiresSoon: Bool
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case aspspName = "aspsp_name"
|
||||||
|
case aspspCountry = "aspsp_country"
|
||||||
|
case validUntil = "valid_until"
|
||||||
|
case lastSyncAt = "last_sync_at"
|
||||||
|
case pollMinutes = "poll_minutes"
|
||||||
|
case lastError = "last_error"
|
||||||
|
case expiresSoon = "expires_soon"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was ein Abruf zurückmeldet.
|
||||||
|
struct Abrufergebnis: Codable, Sendable, Hashable {
|
||||||
|
let geholt: Int
|
||||||
|
let neu: Int
|
||||||
|
let gebucht: Int?
|
||||||
|
let vorgemerkt: Int?
|
||||||
|
let doppelt: Int?
|
||||||
|
let uebertraege: Int?
|
||||||
|
/// Die Verbindung hat kein zugeordnetes Konto – dann wurde nichts geholt.
|
||||||
|
let ohneZuordnung: Bool?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case geholt, neu, gebucht, vorgemerkt, doppelt, uebertraege
|
||||||
|
case ohneZuordnung = "ohne_zuordnung"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Satz für die Rückmeldung.
|
||||||
|
var meldung: String {
|
||||||
|
if ohneZuordnung == true {
|
||||||
|
return String(localized: "bank.ohneZuordnung")
|
||||||
|
}
|
||||||
|
return String(format: String(localized: "bank.abrufErgebnis"), geholt, neu)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Die Bankanbindungen – abrufen, Salden auffrischen.
|
||||||
|
///
|
||||||
|
/// **Herstellen geht hier nicht.** Die Bank leitet fest auf
|
||||||
|
/// `{base_url}/bank/rueckkehr` zurück, registriert bei Enable Banking; dieser
|
||||||
|
/// Weg führt zwangsläufig über den Browser am Rechner. Abrufen dagegen ist
|
||||||
|
/// genau das, was man unterwegs will.
|
||||||
|
struct BankAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var lader: Lader<[Bankverbindung]>?
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
|
@State private var meldung: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let lader {
|
||||||
|
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { liste in
|
||||||
|
inhalt(liste)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "nebenraeume.bank"))
|
||||||
|
.task {
|
||||||
|
guard lader == nil, let client = zustand.client else { return }
|
||||||
|
let neuer = Lader<[Bankverbindung]>(schluessel: "bank/connections") {
|
||||||
|
try await client.bankverbindungen()
|
||||||
|
}
|
||||||
|
lader = neuer
|
||||||
|
await neuer.lade()
|
||||||
|
}
|
||||||
|
.alert(String(localized: "bank.abrufFertig"),
|
||||||
|
isPresented: .constant(meldung != nil)) {
|
||||||
|
Button(String(localized: "allgemein.ok")) { meldung = nil }
|
||||||
|
} message: {
|
||||||
|
Text(meldung ?? "")
|
||||||
|
}
|
||||||
|
.alert(String(localized: "fehler.titel"),
|
||||||
|
isPresented: .constant(vorgang.fehler != nil)) {
|
||||||
|
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
|
||||||
|
} message: {
|
||||||
|
Text(vorgang.fehler ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func inhalt(_ liste: [Bankverbindung]) -> some View {
|
||||||
|
if liste.isEmpty {
|
||||||
|
LeerAnsicht(titel: String(localized: "bank.leerTitel"),
|
||||||
|
erklaerung: String(localized: "bank.leerErklaerung"),
|
||||||
|
symbol: "building.columns")
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(liste) { verbindung in
|
||||||
|
Section {
|
||||||
|
VerbindungsZeile(verbindung: verbindung)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await rufeAb(verbindung) }
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "bank.abrufen"),
|
||||||
|
systemImage: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task { await frischeSalden(verbindung) }
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "bank.saldenAuffrischen"),
|
||||||
|
systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(verbindung.aspspName)
|
||||||
|
} footer: {
|
||||||
|
if let fehler = verbindung.lastError {
|
||||||
|
// Der letzte Fehler steht am Server. Ihn zu
|
||||||
|
// verschweigen hiesse, den Nutzer vergeblich
|
||||||
|
// abrufen zu lassen.
|
||||||
|
Label(fehler, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable { await lader?.aktualisiere() }
|
||||||
|
.disabled(vorgang.laeuft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rufeAb(_ verbindung: Bankverbindung) async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
var ergebnis: Abrufergebnis?
|
||||||
|
let geglueckt = await vorgang.fuehreAus {
|
||||||
|
ergebnis = try await client.rufeBankAb(verbindung.id)
|
||||||
|
}
|
||||||
|
if geglueckt {
|
||||||
|
meldung = ergebnis?.meldung
|
||||||
|
await lader?.aktualisiere()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func frischeSalden(_ verbindung: Bankverbindung) async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
if await vorgang.fuehreAus({ try await client.frischeSaldenAuf(verbindung.id) }) {
|
||||||
|
meldung = String(localized: "bank.saldenAufgefrischt")
|
||||||
|
await lader?.aktualisiere()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct VerbindungsZeile: View {
|
||||||
|
let verbindung: Bankverbindung
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
LabeledContent(String(localized: "bank.land"), value: verbindung.aspspCountry)
|
||||||
|
|
||||||
|
if let zuletzt = verbindung.lastSyncAt {
|
||||||
|
LabeledContent(String(localized: "bank.zuletzt")) {
|
||||||
|
Text(zuletzt.datum.formatted(.relative(presentation: .named)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LabeledContent(String(localized: "bank.zuletzt"),
|
||||||
|
value: String(localized: "bank.nie"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let gueltig = verbindung.validUntil {
|
||||||
|
LabeledContent(String(localized: "bank.gueltigBis")) {
|
||||||
|
Text(gueltig.datum.formatted(.dateTime.day().month().year()))
|
||||||
|
// Eine auslaufende Anbindung bricht sonst ohne
|
||||||
|
// Vorwarnung ab, und niemand weiss warum.
|
||||||
|
.foregroundStyle(verbindung.expiresSoon ? .orange : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbindung.pollMinutes > 0 {
|
||||||
|
LabeledContent(String(localized: "bank.takt")) {
|
||||||
|
Text(String(format: String(localized: "bank.alleNMinuten"),
|
||||||
|
verbindung.pollMinutes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Eine Kategorie anlegen oder ändern.
|
||||||
|
struct KategorieFormular: View {
|
||||||
|
enum Zweck: Equatable, Identifiable {
|
||||||
|
/// Neu in dieser Gruppe. Die Art erbt sie von der Gruppe – eine
|
||||||
|
/// Einnahme-Kategorie in einer Ausgabe-Gruppe gibt es nicht.
|
||||||
|
case neu(Int, Kategorieart)
|
||||||
|
case aendern(Kategorie)
|
||||||
|
|
||||||
|
var id: Int {
|
||||||
|
switch self {
|
||||||
|
case .neu(let gruppe, _): return -gruppe
|
||||||
|
case .aendern(let kategorie): return kategorie.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let zweck: Zweck
|
||||||
|
var fertig: () -> Void
|
||||||
|
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@Environment(\.dismiss) private var schliesse
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var symbol = "more-horizontal"
|
||||||
|
@State private var farbe = Farbwahl.standard
|
||||||
|
@State private var gruppeId = 0
|
||||||
|
@State private var istSparen = false
|
||||||
|
@State private var ohneAuswertung = false
|
||||||
|
@State private var istArchiviert = false
|
||||||
|
@State private var fragtNachLoeschen = false
|
||||||
|
@State private var vorbelegt = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(String(localized: "konto.name"), text: $name)
|
||||||
|
Picker(String(localized: "kategorien.gruppe"), selection: $gruppeId) {
|
||||||
|
ForEach(passendeGruppen) { gruppe in
|
||||||
|
Text(gruppe.name).tag(gruppe.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||||
|
}
|
||||||
|
|
||||||
|
if art == .expense {
|
||||||
|
Section {
|
||||||
|
Toggle(String(localized: "kategorien.istSparen"), isOn: $istSparen)
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "kategorien.istSparenErklaerung"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle(String(localized: "kategorien.ohneAuswertungSchalter"),
|
||||||
|
isOn: $ohneAuswertung)
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "kategorien.ohneAuswertungErklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .aendern = zweck {
|
||||||
|
Section {
|
||||||
|
Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert)
|
||||||
|
Button(String(localized: "kategorien.loeschen"), role: .destructive) {
|
||||||
|
fragtNachLoeschen = true
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "kategorien.archivierenStattLoeschen"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fehler = vorgang.fehler {
|
||||||
|
Section { FehlerZeile(text: fehler) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(titel)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
|
||||||
|
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| vorgang.laeuft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.interactiveDismissDisabled(vorgang.laeuft)
|
||||||
|
.confirmationDialog(String(localized: "kategorien.loeschenFrage"),
|
||||||
|
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||||
|
Button(String(localized: "kategorien.loeschen"), role: .destructive) {
|
||||||
|
Task { await loesche() }
|
||||||
|
}
|
||||||
|
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(String(localized: "kategorien.loeschenErklaerung"))
|
||||||
|
}
|
||||||
|
.onAppear(perform: belegeVor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titel: String {
|
||||||
|
switch zweck {
|
||||||
|
case .neu: return String(localized: "kategorien.neu")
|
||||||
|
case .aendern: return String(localized: "kategorien.aendern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Art diese Kategorie hat – sie steckt in der Gruppe, nicht in
|
||||||
|
/// der Kategorie selbst.
|
||||||
|
private var art: Kategorieart {
|
||||||
|
switch zweck {
|
||||||
|
case .neu(_, let kind): return kind
|
||||||
|
case .aendern(let kategorie): return kategorie.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Umhängen nur innerhalb derselben Art: Eine Ausgabe-Kategorie in einer
|
||||||
|
/// Einnahme-Gruppe wäre ein Widerspruch.
|
||||||
|
private var passendeGruppen: [Kategoriegruppe] {
|
||||||
|
zustand.nachschlag.gruppen.filter { $0.kind == art }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func belegeVor() {
|
||||||
|
guard !vorbelegt else { return }
|
||||||
|
vorbelegt = true
|
||||||
|
switch zweck {
|
||||||
|
case .neu(let gruppe, _):
|
||||||
|
gruppeId = gruppe
|
||||||
|
case .aendern(let kategorie):
|
||||||
|
name = kategorie.name
|
||||||
|
symbol = kategorie.icon
|
||||||
|
farbe = kategorie.color
|
||||||
|
gruppeId = kategorie.groupId
|
||||||
|
istSparen = kategorie.spendingIsGood
|
||||||
|
ohneAuswertung = kategorie.excludedFromStats
|
||||||
|
istArchiviert = kategorie.isArchived
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sichere() async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
// Sparen gibt es nur bei Ausgaben. Beim Wechsel der Art bliebe der
|
||||||
|
// Haken sonst gesetzt und wirkte still weiter.
|
||||||
|
let sparen = art == .expense && istSparen
|
||||||
|
|
||||||
|
let geglueckt = await vorgang.fuehreAus {
|
||||||
|
switch zweck {
|
||||||
|
case .neu:
|
||||||
|
_ = try await client.legeKategorieAn(KategorieAnlegen(
|
||||||
|
groupId: gruppeId, name: sauber, icon: symbol, color: farbe,
|
||||||
|
excludedFromStats: ohneAuswertung, spendingIsGood: sparen))
|
||||||
|
case .aendern(let kategorie):
|
||||||
|
_ = try await client.aendereKategorie(kategorie.id, KategorieAendern(
|
||||||
|
name: sauber, groupId: gruppeId, icon: symbol, color: farbe,
|
||||||
|
isArchived: istArchiviert, excludedFromStats: ohneAuswertung,
|
||||||
|
spendingIsGood: sparen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if geglueckt {
|
||||||
|
fertig()
|
||||||
|
schliesse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loesche() async {
|
||||||
|
guard let client = zustand.client, case .aendern(let kategorie) = zweck else { return }
|
||||||
|
if await vorgang.fuehreAus({ try await client.loescheKategorie(kategorie.id) }) {
|
||||||
|
fertig()
|
||||||
|
schliesse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Kategoriegruppe anlegen oder ändern.
|
||||||
|
struct GruppenFormular: View {
|
||||||
|
enum Zweck: Equatable, Identifiable {
|
||||||
|
case neu
|
||||||
|
case aendern(Kategoriegruppe)
|
||||||
|
|
||||||
|
var id: Int {
|
||||||
|
switch self {
|
||||||
|
case .neu: return 0
|
||||||
|
case .aendern(let gruppe): return gruppe.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let zweck: Zweck
|
||||||
|
var fertig: () -> Void
|
||||||
|
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@Environment(\.dismiss) private var schliesse
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var art: Kategorieart = .expense
|
||||||
|
@State private var symbol = "list"
|
||||||
|
@State private var farbe = Farbwahl.standard
|
||||||
|
@State private var fragtNachLoeschen = false
|
||||||
|
@State private var vorbelegt = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(String(localized: "konto.name"), text: $name)
|
||||||
|
if case .neu = zweck {
|
||||||
|
Picker(String(localized: "kategorien.art"), selection: $art) {
|
||||||
|
Text(String(localized: "kategorien.ausgaben")).tag(Kategorieart.expense)
|
||||||
|
Text(String(localized: "kategorien.einnahmen")).tag(Kategorieart.income)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
} else {
|
||||||
|
// Die Art einer Gruppe nachträglich zu wechseln würde
|
||||||
|
// alle ihre Kategorien die Seite wechseln lassen.
|
||||||
|
LabeledContent(String(localized: "kategorien.art"),
|
||||||
|
value: art == .income
|
||||||
|
? String(localized: "kategorien.einnahmen")
|
||||||
|
: String(localized: "kategorien.ausgaben"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .aendern = zweck {
|
||||||
|
Section {
|
||||||
|
Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) {
|
||||||
|
fragtNachLoeschen = true
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "kategorien.gruppeLoeschenErklaerung"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fehler = vorgang.fehler {
|
||||||
|
Section { FehlerZeile(text: fehler) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "kategorien.gruppe"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
|
||||||
|
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| vorgang.laeuft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(String(localized: "kategorien.gruppeLoeschenFrage"),
|
||||||
|
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||||
|
Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) {
|
||||||
|
Task { await loesche() }
|
||||||
|
}
|
||||||
|
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
guard !vorbelegt else { return }
|
||||||
|
vorbelegt = true
|
||||||
|
if case .aendern(let gruppe) = zweck {
|
||||||
|
name = gruppe.name
|
||||||
|
art = gruppe.kind
|
||||||
|
symbol = gruppe.icon
|
||||||
|
farbe = gruppe.color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sichere() async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let geglueckt = await vorgang.fuehreAus {
|
||||||
|
switch zweck {
|
||||||
|
case .neu:
|
||||||
|
_ = try await client.legeGruppeAn(GruppeAnlegen(
|
||||||
|
name: sauber, kind: art, icon: symbol, color: farbe))
|
||||||
|
case .aendern(let gruppe):
|
||||||
|
_ = try await client.aendereGruppe(gruppe.id, GruppeAendern(
|
||||||
|
name: sauber, icon: symbol, color: farbe))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if geglueckt {
|
||||||
|
fertig()
|
||||||
|
schliesse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loesche() async {
|
||||||
|
guard let client = zustand.client, case .aendern(let gruppe) = zweck else { return }
|
||||||
|
if await vorgang.fuehreAus({ try await client.loescheGruppe(gruppe.id) }) {
|
||||||
|
fertig()
|
||||||
|
schliesse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Kategorien, nach Gruppen. Antippen öffnet die Kategorie samt ihren
|
||||||
|
/// Erkennungswörtern.
|
||||||
|
struct KategorienAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
|
@State private var bearbeitet: KategorieFormular.Zweck?
|
||||||
|
@State private var gruppeBearbeiten: GruppenFormular.Zweck?
|
||||||
|
@State private var zeigeArchivierte = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if zustand.nachschlag.gruppen.isEmpty {
|
||||||
|
Section {
|
||||||
|
Text(String(localized: "kategorien.leer"))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(zustand.nachschlag.gruppen) { gruppe in
|
||||||
|
Section {
|
||||||
|
ForEach(sichtbar(gruppe)) { kategorie in
|
||||||
|
NavigationLink {
|
||||||
|
KategorieAnsicht(kategorie: kategorie)
|
||||||
|
} label: {
|
||||||
|
KategorieZeile(kategorie: kategorie)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button {
|
||||||
|
bearbeitet = .aendern(kategorie)
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "allgemein.bearbeiten"),
|
||||||
|
systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.tint(.marke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
bearbeitet = .neu(gruppe.id, gruppe.kind)
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "kategorien.neu"), systemImage: "plus")
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: Symbole.sf(gruppe.icon))
|
||||||
|
.foregroundStyle(Color(hex: gruppe.color))
|
||||||
|
Text(gruppe.name)
|
||||||
|
Text(gruppe.kind == .income
|
||||||
|
? String(localized: "kategorien.einnahmen")
|
||||||
|
: String(localized: "kategorien.ausgaben"))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
gruppeBearbeiten = .aendern(gruppe)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textCase(nil)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.navigationTitle(String(localized: "nebenraeume.kategorien"))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Toggle(String(localized: "kategorien.archivierteZeigen"),
|
||||||
|
isOn: $zeigeArchivierte)
|
||||||
|
Button {
|
||||||
|
gruppeBearbeiten = .neu
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "kategorien.neueGruppe"),
|
||||||
|
systemImage: "folder.badge.plus")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await zustand.ladeNachschlag() }
|
||||||
|
.sheet(item: $bearbeitet) { zweck in
|
||||||
|
KategorieFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } }
|
||||||
|
.environment(zustand)
|
||||||
|
}
|
||||||
|
.sheet(item: $gruppeBearbeiten) { zweck in
|
||||||
|
GruppenFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } }
|
||||||
|
.environment(zustand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sichtbar(_ gruppe: Kategoriegruppe) -> [Kategorie] {
|
||||||
|
gruppe.categories.filter { zeigeArchivierte || !$0.isArchived }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct KategorieZeile: View {
|
||||||
|
let kategorie: Kategorie
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30)
|
||||||
|
Text(kategorie.name)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 6)
|
||||||
|
ForEach(merkmale, id: \.self) { merkmal in
|
||||||
|
Text(merkmal)
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(.quaternary, in: Capsule())
|
||||||
|
}
|
||||||
|
if kategorie.keywordCount > 0 {
|
||||||
|
Label("\(kategorie.keywordCount)", systemImage: "text.magnifyingglass")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur was vom Normalfall abweicht. Eine Plakette an jeder Zeile sagt
|
||||||
|
/// nichts mehr.
|
||||||
|
private var merkmale: [String] {
|
||||||
|
var liste: [String] = []
|
||||||
|
if kategorie.isArchived { liste.append(String(localized: "konten.archiviert")) }
|
||||||
|
if kategorie.spendingIsGood { liste.append(String(localized: "kategorien.sparen")) }
|
||||||
|
if kategorie.excludedFromStats {
|
||||||
|
liste.append(String(localized: "kategorien.ohneAuswertung"))
|
||||||
|
}
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Eine Kategorie mit ihren Erkennungswörtern
|
||||||
|
|
||||||
|
/// Was die Erkennung an dieser Kategorie festmacht.
|
||||||
|
///
|
||||||
|
/// Erkennungswörter wirken **nur auf künftige Importe** – an bestehenden
|
||||||
|
/// Buchungen ändert sich nichts. Das steht auch so unter der Liste, sonst
|
||||||
|
/// wartet jemand vergeblich darauf, dass sich etwas rührt.
|
||||||
|
struct KategorieAnsicht: View {
|
||||||
|
let kategorie: Kategorie
|
||||||
|
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var lader: Lader<[Erkennungswort]>?
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
|
@State private var neuesWort = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let lader {
|
||||||
|
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { woerter in
|
||||||
|
liste(woerter)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(kategorie.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task {
|
||||||
|
guard lader == nil, let client = zustand.client else { return }
|
||||||
|
let neuer = Lader<[Erkennungswort]>(schluessel: "keywords/\(kategorie.id)") {
|
||||||
|
try await client.erkennungswoerter(kategorie: kategorie.id)
|
||||||
|
}
|
||||||
|
lader = neuer
|
||||||
|
await neuer.lade()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func liste(_ woerter: [Erkennungswort]) -> some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(woerter) { wort in
|
||||||
|
HStack {
|
||||||
|
Text(wort.word)
|
||||||
|
Spacer()
|
||||||
|
if let cents = wort.amountCents {
|
||||||
|
Text(Geldbetrag(cents: cents,
|
||||||
|
waehrung: zustand.einstellungen.basiswaehrung)
|
||||||
|
.formatiert())
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await loesche(wort) }
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "allgemein.loeschen"), systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
TextField(String(localized: "kategorien.wortHinzufuegen"), text: $neuesWort)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.onSubmit { Task { await fuegeHinzu() } }
|
||||||
|
Button {
|
||||||
|
Task { await fuegeHinzu() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
.disabled(neuesWort.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| vorgang.laeuft)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: "kategorien.erkennungswoerter"))
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "kategorien.erkennungErklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fehler = vorgang.fehler {
|
||||||
|
Section { FehlerZeile(text: fehler) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable { await lader?.aktualisiere() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fuegeHinzu() async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
let wort = neuesWort.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !wort.isEmpty else { return }
|
||||||
|
let geglueckt = await vorgang.fuehreAus {
|
||||||
|
_ = try await client.legeErkennungswortAn(
|
||||||
|
kategorie: kategorie.id, ErkennungswortAnlegen(word: wort))
|
||||||
|
}
|
||||||
|
if geglueckt {
|
||||||
|
neuesWort = ""
|
||||||
|
await lader?.aktualisiere()
|
||||||
|
// Die Kategorie führt die Anzahl mit; ohne das bleibt die
|
||||||
|
// Plakette in der Übersicht auf dem alten Stand stehen.
|
||||||
|
await zustand.ladeNachschlag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loesche(_ wort: Erkennungswort) async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) {
|
||||||
|
await lader?.aktualisiere()
|
||||||
|
await zustand.ladeNachschlag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Schlagwörter: frei vergebene Etiketten, quer zur Kategorie.
|
||||||
|
///
|
||||||
|
/// Die Kategorie beantwortet „wofür war das Geld". Alles, was quer dazu
|
||||||
|
/// liegt – „Urlaub Island", „steuerlich absetzbar" –, passt in keine, weil
|
||||||
|
/// eine Buchung davon mehrere auf einmal sein kann.
|
||||||
|
struct TagsAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
|
@State private var bearbeitet: Schlagwort?
|
||||||
|
@State private var neuerName = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(zustand.nachschlag.schlagwoerter) { etikett in
|
||||||
|
Button {
|
||||||
|
bearbeitet = etikett
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: etikett.color))
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
Text(etikett.name)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await loesche(etikett) }
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "allgemein.loeschen"), systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
TextField(String(localized: "tags.neu"), text: $neuerName)
|
||||||
|
.onSubmit { Task { await legeAn() } }
|
||||||
|
Button {
|
||||||
|
Task { await legeAn() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
.disabled(neuerName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| vorgang.laeuft)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "tags.erklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fehler = vorgang.fehler {
|
||||||
|
Section { FehlerZeile(text: fehler) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.navigationTitle(String(localized: "nebenraeume.tags"))
|
||||||
|
.refreshable { await zustand.ladeNachschlag() }
|
||||||
|
.sheet(item: $bearbeitet) { etikett in
|
||||||
|
TagFormular(etikett: etikett) { Task { await zustand.ladeNachschlag() } }
|
||||||
|
.environment(zustand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func legeAn() async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
let name = neuerName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty else { return }
|
||||||
|
// Reihum eine Farbe, damit nicht alle grau sind. Ändern geht danach.
|
||||||
|
let farbe = Farbwahl.palette[zustand.nachschlag.schlagwoerter.count % Farbwahl.palette.count]
|
||||||
|
let geglueckt = await vorgang.fuehreAus {
|
||||||
|
_ = try await client.legeTagAn(TagAnlegen(name: name, color: farbe))
|
||||||
|
}
|
||||||
|
if geglueckt {
|
||||||
|
neuerName = ""
|
||||||
|
await zustand.ladeNachschlag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loesche(_ etikett: Schlagwort) async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
if await vorgang.fuehreAus({ try await client.loescheTag(etikett.id) }) {
|
||||||
|
await zustand.ladeNachschlag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Schlagwort umbenennen oder umfärben.
|
||||||
|
struct TagFormular: View {
|
||||||
|
let etikett: Schlagwort
|
||||||
|
var fertig: () -> Void
|
||||||
|
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@Environment(\.dismiss) private var schliesse
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var farbe = Farbwahl.standard
|
||||||
|
@State private var vorbelegt = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(String(localized: "konto.name"), text: $name)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(Farbwahl.palette, id: \.self) { wert in
|
||||||
|
Button { farbe = wert } label: {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: wert))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.overlay {
|
||||||
|
if wert == farbe {
|
||||||
|
Circle().strokeBorder(.primary, lineWidth: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let fehler = vorgang.fehler {
|
||||||
|
Section { FehlerZeile(text: fehler) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "nebenraeume.tags"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
|
||||||
|
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| vorgang.laeuft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
guard !vorbelegt else { return }
|
||||||
|
vorbelegt = true
|
||||||
|
name = etikett.name
|
||||||
|
farbe = etikett.color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sichere() async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let geglueckt = await vorgang.fuehreAus {
|
||||||
|
_ = try await client.aendereTag(etikett.id,
|
||||||
|
TagAendern(name: sauber, color: farbe))
|
||||||
|
}
|
||||||
|
if geglueckt {
|
||||||
|
fertig()
|
||||||
|
schliesse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Den zweiten Faktor einrichten.
|
||||||
|
///
|
||||||
|
/// Bis Stufe 5 verwies die App dafür in den Browser. Drei Schritte:
|
||||||
|
/// Geheimnis anlegen, in die Authenticator-App übernehmen, mit einem Code
|
||||||
|
/// scharfschalten.
|
||||||
|
///
|
||||||
|
/// **Abschalten geht hier nicht**, und das ist Absicht: Es verlangt das
|
||||||
|
/// Kontopasswort, und die App speichert keines. Wer es abschalten will,
|
||||||
|
/// macht das am Rechner – ein Vollzugang auf diesem Gerät soll den Schutz
|
||||||
|
/// des Kontos nicht aufheben können.
|
||||||
|
struct ZweiterFaktorAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var lader: Lader<ZweiterFaktorStand>?
|
||||||
|
@State private var vorgang = Speichervorgang()
|
||||||
|
@State private var einrichtung: ZweiterFaktorEinrichtung?
|
||||||
|
@State private var code = ""
|
||||||
|
@State private var rettungscodes: [String]?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let lader {
|
||||||
|
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in
|
||||||
|
inhalt(stand)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "zweifaktor.titel"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task {
|
||||||
|
guard lader == nil, let client = zustand.client else { return }
|
||||||
|
let neuer = Lader<ZweiterFaktorStand>(schluessel: "auth/2fa/status") {
|
||||||
|
try await client.zweiterFaktorStand()
|
||||||
|
}
|
||||||
|
lader = neuer
|
||||||
|
await neuer.lade()
|
||||||
|
}
|
||||||
|
.sheet(item: Binding(get: { rettungscodes.map(Codeliste.init) },
|
||||||
|
set: { if $0 == nil { rettungscodes = nil } })) { liste in
|
||||||
|
RettungscodeBlatt(codes: liste.codes) {
|
||||||
|
rettungscodes = nil
|
||||||
|
Task { await lader?.aktualisiere() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur da, um die Codes an `.sheet(item:)` zu geben.
|
||||||
|
private struct Codeliste: Identifiable {
|
||||||
|
let codes: [String]
|
||||||
|
var id: String { codes.joined() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func inhalt(_ stand: ZweiterFaktorStand) -> some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
LabeledContent(String(localized: "zweifaktor.stand")) {
|
||||||
|
Text(stand.enabled ? String(localized: "zweifaktor.aktiv")
|
||||||
|
: String(localized: "zweifaktor.inaktiv"))
|
||||||
|
.foregroundStyle(stand.enabled ? .green : .secondary)
|
||||||
|
}
|
||||||
|
if stand.enabled {
|
||||||
|
LabeledContent(String(localized: "zweifaktor.rettungscodes"),
|
||||||
|
value: "\(stand.recoveryCodesLeft)")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(stand.enabled ? String(localized: "zweifaktor.aktivErklaerung")
|
||||||
|
: String(localized: "zweifaktor.inaktivErklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stand.enabled {
|
||||||
|
if let einrichtung {
|
||||||
|
Section {
|
||||||
|
// Auf dem Telefon selbst ist ein QR-Code sinnlos – die
|
||||||
|
// Authenticator-App liegt daneben. Der Link führt
|
||||||
|
// direkt hinein, das Geheimnis steht zum Abtippen da.
|
||||||
|
Link(destination: URL(string: einrichtung.otpauthUri)
|
||||||
|
?? URL(string: "https://example.invalid")!) {
|
||||||
|
Label(String(localized: "zweifaktor.inAppOeffnen"),
|
||||||
|
systemImage: "arrow.up.forward.app")
|
||||||
|
}
|
||||||
|
LabeledContent(String(localized: "zweifaktor.geheimnis")) {
|
||||||
|
Text(einrichtung.secret)
|
||||||
|
.font(.footnote.monospaced())
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: "zweifaktor.schrittEins"))
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "zweifaktor.geheimnisErklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField(String(localized: "zweifaktor.code"), text: $code)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.textContentType(.oneTimeCode)
|
||||||
|
.monospaced()
|
||||||
|
Button(String(localized: "zweifaktor.aktivieren")) {
|
||||||
|
Task { await aktiviere() }
|
||||||
|
}
|
||||||
|
.disabled(code.count < 6 || vorgang.laeuft)
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: "zweifaktor.schrittZwei"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
Button(String(localized: "zweifaktor.einrichten")) {
|
||||||
|
Task { await starte() }
|
||||||
|
}
|
||||||
|
.disabled(vorgang.laeuft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fehler = vorgang.fehler {
|
||||||
|
Section { FehlerZeile(text: fehler) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func starte() async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
var neu: ZweiterFaktorEinrichtung?
|
||||||
|
if await vorgang.fuehreAus({ neu = try await client.richteZweitenFaktorEin() }) {
|
||||||
|
einrichtung = neu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func aktiviere() async {
|
||||||
|
guard let client = zustand.client else { return }
|
||||||
|
var codes: Rettungscodes?
|
||||||
|
let geglueckt = await vorgang.fuehreAus {
|
||||||
|
codes = try await client.aktiviereZweitenFaktor(code: code)
|
||||||
|
}
|
||||||
|
if geglueckt {
|
||||||
|
code = ""
|
||||||
|
einrichtung = nil
|
||||||
|
rettungscodes = codes?.codes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Rettungscodes – einmal zu sehen, danach nie wieder.
|
||||||
|
///
|
||||||
|
/// Deshalb lässt sich dieses Blatt nicht wegwischen: Wer es versehentlich
|
||||||
|
/// schliesst, bekommt neue nur mit dem Kontopasswort am Rechner.
|
||||||
|
private struct RettungscodeBlatt: View {
|
||||||
|
let codes: [String]
|
||||||
|
let fertig: () -> Void
|
||||||
|
|
||||||
|
@State private var abgehakt = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(codes, id: \.self) { code in
|
||||||
|
Text(code)
|
||||||
|
.font(.body.monospaced())
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: "zweifaktor.rettungscodes"))
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "zweifaktor.rettungscodesErklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ShareLink(item: codes.joined(separator: "\n")) {
|
||||||
|
Label(String(localized: "zweifaktor.codesSichern"),
|
||||||
|
systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
Toggle(String(localized: "zweifaktor.codesNotiert"), isOn: $abgehakt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "zweifaktor.aktiv"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.interactiveDismissDisabled()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(String(localized: "allgemein.fertig"), action: fertig)
|
||||||
|
.disabled(!abgehakt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ enum Ablage {
|
|||||||
static let serverAdresse = "certain.serverAdresse"
|
static let serverAdresse = "certain.serverAdresse"
|
||||||
static let marke = "certain.marke"
|
static let marke = "certain.marke"
|
||||||
static let benutzername = "certain.benutzername"
|
static let benutzername = "certain.benutzername"
|
||||||
|
static let pin = "certain.pin"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Server-Adresse
|
// MARK: - Server-Adresse
|
||||||
@@ -71,9 +73,54 @@ enum Ablage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Bildschirmsperre
|
||||||
|
|
||||||
|
/// Die PIN selbst wird nie abgelegt, nur ein gesalzener SHA-256 davon.
|
||||||
|
/// Das Salz liegt daneben, beides im Schlüsselbund als `salz:hash` in Hex.
|
||||||
|
///
|
||||||
|
/// Die PIN verdeckt die Oberfläche, sie verschlüsselt nicht die
|
||||||
|
/// Gerätemarke – wer das Telefon entsperrt hat und es auslesen kann,
|
||||||
|
/// käme an die Marke. Gegen Mitlesen über die Schulter und gegen ein
|
||||||
|
/// kurz liegengelassenes Telefon hilft sie, gegen Forensik nicht.
|
||||||
|
static var hatPIN: Bool { schluesselbundLesen(Schluessel.pin) != nil }
|
||||||
|
|
||||||
|
static func setzePIN(_ pin: String) {
|
||||||
|
var salz = Data(count: 16)
|
||||||
|
_ = salz.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||||
|
schluesselbundSchreiben(Schluessel.pin, "\(hex(salz)):\(hex(verhashe(pin, salz: salz)))")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stimmtPIN(_ pin: String) -> Bool {
|
||||||
|
guard let abgelegt = schluesselbundLesen(Schluessel.pin) else { return false }
|
||||||
|
let teile = abgelegt.split(separator: ":", maxSplits: 1)
|
||||||
|
guard teile.count == 2, let salz = daten(hex: String(teile[0])) else { return false }
|
||||||
|
return hex(verhashe(pin, salz: salz)) == String(teile[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loeschePIN() { schluesselbundLoeschen(Schluessel.pin) }
|
||||||
|
|
||||||
|
private static func verhashe(_ pin: String, salz: Data) -> Data {
|
||||||
|
Data(SHA256.hash(data: salz + Data(pin.utf8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hex(_ daten: Data) -> String {
|
||||||
|
daten.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func daten(hex text: String) -> Data? {
|
||||||
|
guard text.count % 2 == 0 else { return nil }
|
||||||
|
var roh = Data(); var rest = Substring(text)
|
||||||
|
while !rest.isEmpty {
|
||||||
|
guard let wert = UInt8(rest.prefix(2), radix: 16) else { return nil }
|
||||||
|
roh.append(wert); rest = rest.dropFirst(2)
|
||||||
|
}
|
||||||
|
return roh
|
||||||
|
}
|
||||||
|
|
||||||
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
|
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
|
||||||
static func raeumeAuf() {
|
static func raeumeAuf() {
|
||||||
marke = nil
|
marke = nil
|
||||||
|
loeschePIN()
|
||||||
benutzername = nil
|
benutzername = nil
|
||||||
serverAdresse = nil
|
serverAdresse = nil
|
||||||
Zwischenspeicher.leere()
|
Zwischenspeicher.leere()
|
||||||
|
|||||||
@@ -288,4 +288,188 @@ extension CertainClient {
|
|||||||
func loescheKonto(_ id: Int) async throws {
|
func loescheKonto(_ id: Int) async throws {
|
||||||
try await schickeStumm(.delete, "accounts/\(id)")
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ Vollzugang. Wer eine Ausnahme braucht, trägt sie bewusst in `project.yml` ein.
|
|||||||
| `Budget/` | Soll gegen Ist, Monat für Monat |
|
| `Budget/` | Soll gegen Ist, Monat für Monat |
|
||||||
| `Buchungen/` | Buchungsliste mit Suche, Formular zum Anlegen und Ändern |
|
| `Buchungen/` | Buchungsliste mit Suche, Formular zum Anlegen und Ändern |
|
||||||
| `Abos/` | Verträge und Abos |
|
| `Abos/` | Verträge und Abos |
|
||||||
|
| `Arbeitsliste/` | Eingang, Übertragsvorschläge, Dubletten |
|
||||||
|
| `Nebenraeume/` | Kategorien, Schlagwörter, Bank, zweiter Faktor |
|
||||||
| `Konten/` | Kontenliste und Konto-Formular |
|
| `Konten/` | Kontenliste und Konto-Formular |
|
||||||
| `Texte/` | Übersetzungen, Deutsch und Englisch zeilengleich |
|
| `Texte/` | Übersetzungen, Deutsch und Englisch zeilengleich |
|
||||||
| `Tests/` | Logiktests ohne Netz, plus die Modellprüfung gegen `Fixtures/antworten.json` |
|
| `Tests/` | Logiktests ohne Netz, plus die Modellprüfung gegen `Fixtures/antworten.json` |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"must_change_password": false,
|
"must_change_password": false,
|
||||||
"must_change_password_reason": null,
|
"must_change_password_reason": null,
|
||||||
"totp_enabled": false,
|
"totp_enabled": false,
|
||||||
"created_at": "2026-10-21T18:22:28.427216",
|
"created_at": "2026-10-21T19:57:08.443998",
|
||||||
"mfa_setup_required": false
|
"mfa_setup_required": false
|
||||||
},
|
},
|
||||||
"einstellungen": [
|
"einstellungen": [
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
"position": 0,
|
"position": 0,
|
||||||
"savings_category_id": null,
|
"savings_category_id": null,
|
||||||
"identifiers": [],
|
"identifiers": [],
|
||||||
"balance_cents": 430750,
|
"balance_cents": 419874,
|
||||||
"balance_base_cents": 430750,
|
"balance_base_cents": 419874,
|
||||||
"bank_balance_cents": null,
|
"bank_balance_cents": null,
|
||||||
"bank_balance_at": null,
|
"bank_balance_at": null,
|
||||||
"bank_balance_vergleichbar": false
|
"bank_balance_vergleichbar": false
|
||||||
@@ -84,6 +84,27 @@
|
|||||||
"bank_balance_cents": null,
|
"bank_balance_cents": null,
|
||||||
"bank_balance_at": null,
|
"bank_balance_at": null,
|
||||||
"bank_balance_vergleichbar": false
|
"bank_balance_vergleichbar": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Zweitkonto",
|
||||||
|
"type": "girokonto",
|
||||||
|
"currency": "EUR",
|
||||||
|
"icon": "wallet",
|
||||||
|
"color": "#3f51b5",
|
||||||
|
"opening_balance_cents": 0,
|
||||||
|
"opening_date": null,
|
||||||
|
"credit_limit_cents": null,
|
||||||
|
"is_locked": false,
|
||||||
|
"is_archived": false,
|
||||||
|
"position": 2,
|
||||||
|
"savings_category_id": null,
|
||||||
|
"identifiers": [],
|
||||||
|
"balance_cents": 7500,
|
||||||
|
"balance_base_cents": 7500,
|
||||||
|
"bank_balance_cents": null,
|
||||||
|
"bank_balance_at": null,
|
||||||
|
"bank_balance_vergleichbar": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"kategoriegruppen": [
|
"kategoriegruppen": [
|
||||||
@@ -108,7 +129,7 @@
|
|||||||
"is_archived": false,
|
"is_archived": false,
|
||||||
"excluded_from_stats": false,
|
"excluded_from_stats": false,
|
||||||
"spending_is_good": false,
|
"spending_is_good": false,
|
||||||
"keyword_count": 0
|
"keyword_count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
@@ -456,6 +477,136 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"buchungen": [
|
"buchungen": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "income",
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"time": null,
|
||||||
|
"account_id": 3,
|
||||||
|
"to_account_id": null,
|
||||||
|
"account_currency": "EUR",
|
||||||
|
"amount_cents": 7500,
|
||||||
|
"to_amount_cents": null,
|
||||||
|
"fx_rate": null,
|
||||||
|
"amount_eur_cents": 7500,
|
||||||
|
"amount_base_cents": 7500,
|
||||||
|
"category_id": null,
|
||||||
|
"budget_treatment": null,
|
||||||
|
"budget_category_id": null,
|
||||||
|
"subscription_id": null,
|
||||||
|
"budget_period": "2026-10",
|
||||||
|
"budget_period_manuell": false,
|
||||||
|
"import_batch_id": null,
|
||||||
|
"herkunft": "manuell",
|
||||||
|
"payee": "Dauerauftrag",
|
||||||
|
"description": null,
|
||||||
|
"notes": null,
|
||||||
|
"tag_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "expense",
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"time": null,
|
||||||
|
"account_id": 1,
|
||||||
|
"to_account_id": null,
|
||||||
|
"account_currency": "EUR",
|
||||||
|
"amount_cents": 7500,
|
||||||
|
"to_amount_cents": null,
|
||||||
|
"fx_rate": null,
|
||||||
|
"amount_eur_cents": 7500,
|
||||||
|
"amount_base_cents": 7500,
|
||||||
|
"category_id": null,
|
||||||
|
"budget_treatment": null,
|
||||||
|
"budget_category_id": null,
|
||||||
|
"subscription_id": null,
|
||||||
|
"budget_period": "2026-10",
|
||||||
|
"budget_period_manuell": false,
|
||||||
|
"import_batch_id": null,
|
||||||
|
"herkunft": "manuell",
|
||||||
|
"payee": "Dauerauftrag",
|
||||||
|
"description": null,
|
||||||
|
"notes": null,
|
||||||
|
"tag_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "expense",
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"time": null,
|
||||||
|
"account_id": 1,
|
||||||
|
"to_account_id": null,
|
||||||
|
"account_currency": "EUR",
|
||||||
|
"amount_cents": 1043,
|
||||||
|
"to_amount_cents": null,
|
||||||
|
"fx_rate": null,
|
||||||
|
"amount_eur_cents": 1043,
|
||||||
|
"amount_base_cents": 1043,
|
||||||
|
"category_id": 1,
|
||||||
|
"budget_treatment": null,
|
||||||
|
"budget_category_id": null,
|
||||||
|
"subscription_id": null,
|
||||||
|
"budget_period": "2026-10",
|
||||||
|
"budget_period_manuell": false,
|
||||||
|
"import_batch_id": null,
|
||||||
|
"herkunft": "manuell",
|
||||||
|
"payee": "Kiosk",
|
||||||
|
"description": null,
|
||||||
|
"notes": null,
|
||||||
|
"tag_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "expense",
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"time": null,
|
||||||
|
"account_id": 1,
|
||||||
|
"to_account_id": null,
|
||||||
|
"account_currency": "EUR",
|
||||||
|
"amount_cents": 1043,
|
||||||
|
"to_amount_cents": null,
|
||||||
|
"fx_rate": null,
|
||||||
|
"amount_eur_cents": 1043,
|
||||||
|
"amount_base_cents": 1043,
|
||||||
|
"category_id": 1,
|
||||||
|
"budget_treatment": null,
|
||||||
|
"budget_category_id": null,
|
||||||
|
"subscription_id": null,
|
||||||
|
"budget_period": "2026-10",
|
||||||
|
"budget_period_manuell": false,
|
||||||
|
"import_batch_id": null,
|
||||||
|
"herkunft": "manuell",
|
||||||
|
"payee": "Kiosk",
|
||||||
|
"description": null,
|
||||||
|
"notes": null,
|
||||||
|
"tag_ids": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "expense",
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"time": null,
|
||||||
|
"account_id": 1,
|
||||||
|
"to_account_id": null,
|
||||||
|
"account_currency": "EUR",
|
||||||
|
"amount_cents": 1290,
|
||||||
|
"to_amount_cents": null,
|
||||||
|
"fx_rate": null,
|
||||||
|
"amount_eur_cents": 1290,
|
||||||
|
"amount_base_cents": 1290,
|
||||||
|
"category_id": null,
|
||||||
|
"budget_treatment": null,
|
||||||
|
"budget_category_id": null,
|
||||||
|
"subscription_id": null,
|
||||||
|
"budget_period": "2026-10",
|
||||||
|
"budget_period_manuell": false,
|
||||||
|
"import_batch_id": null,
|
||||||
|
"herkunft": "shortcut",
|
||||||
|
"payee": "Baeckerei",
|
||||||
|
"description": null,
|
||||||
|
"notes": null,
|
||||||
|
"tag_ids": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"type": "income",
|
"type": "income",
|
||||||
@@ -620,7 +771,7 @@
|
|||||||
"icon": "landmark",
|
"icon": "landmark",
|
||||||
"color": "#3f51b5",
|
"color": "#3f51b5",
|
||||||
"planned_cents": 50000,
|
"planned_cents": 50000,
|
||||||
"actual_cents": 4250,
|
"actual_cents": 6336,
|
||||||
"over": false,
|
"over": false,
|
||||||
"good": false,
|
"good": false,
|
||||||
"aus_vertraegen_cents": 108,
|
"aus_vertraegen_cents": 108,
|
||||||
@@ -631,8 +782,8 @@
|
|||||||
"icon": "home",
|
"icon": "home",
|
||||||
"color": "#3f51b5",
|
"color": "#3f51b5",
|
||||||
"planned_cents": 50000,
|
"planned_cents": 50000,
|
||||||
"actual_cents": 4250,
|
"actual_cents": 6336,
|
||||||
"delta_cents": 45750,
|
"delta_cents": 43664,
|
||||||
"over": false,
|
"over": false,
|
||||||
"modus": "fest",
|
"modus": "fest",
|
||||||
"zuschlag_cents": 0,
|
"zuschlag_cents": 0,
|
||||||
@@ -958,7 +1109,7 @@
|
|||||||
},
|
},
|
||||||
"totals": {
|
"totals": {
|
||||||
"planned_expense": 50000,
|
"planned_expense": 50000,
|
||||||
"actual_expense": 4250,
|
"actual_expense": 6336,
|
||||||
"planned_income": 0,
|
"planned_income": 0,
|
||||||
"received_income": 0
|
"received_income": 0
|
||||||
}
|
}
|
||||||
@@ -1016,8 +1167,8 @@
|
|||||||
"icon": "wallet",
|
"icon": "wallet",
|
||||||
"color": "#3f51b5",
|
"color": "#3f51b5",
|
||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
"balance_cents": 430750,
|
"balance_cents": 419874,
|
||||||
"balance_base_cents": 430750,
|
"balance_base_cents": 419874,
|
||||||
"archived": false,
|
"archived": false,
|
||||||
"locked": false
|
"locked": false
|
||||||
},
|
},
|
||||||
@@ -1031,9 +1182,20 @@
|
|||||||
"balance_base_cents": 535000,
|
"balance_base_cents": 535000,
|
||||||
"archived": false,
|
"archived": false,
|
||||||
"locked": true
|
"locked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Zweitkonto",
|
||||||
|
"icon": "wallet",
|
||||||
|
"color": "#3f51b5",
|
||||||
|
"currency": "EUR",
|
||||||
|
"balance_cents": 7500,
|
||||||
|
"balance_base_cents": 7500,
|
||||||
|
"archived": false,
|
||||||
|
"locked": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"total": 430750,
|
"total": 427374,
|
||||||
"gebunden": 535000,
|
"gebunden": 535000,
|
||||||
"currency": "EUR"
|
"currency": "EUR"
|
||||||
},
|
},
|
||||||
@@ -1043,7 +1205,7 @@
|
|||||||
"name": "Fixkosten",
|
"name": "Fixkosten",
|
||||||
"color": "#3f51b5",
|
"color": "#3f51b5",
|
||||||
"planned": 50000,
|
"planned": 50000,
|
||||||
"actual": 4250,
|
"actual": 6336,
|
||||||
"over": false,
|
"over": false,
|
||||||
"good": false
|
"good": false
|
||||||
}
|
}
|
||||||
@@ -1053,14 +1215,74 @@
|
|||||||
"group_by": "group"
|
"group_by": "group"
|
||||||
},
|
},
|
||||||
"einnahmenAusgaben": {
|
"einnahmenAusgaben": {
|
||||||
"income": 350000,
|
"income": 357500,
|
||||||
"expense": 4250,
|
"expense": 6336,
|
||||||
"net": 345750,
|
"net": 351164,
|
||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
"label": "2026-10"
|
"label": "2026-10"
|
||||||
},
|
},
|
||||||
"letzteBuchungen": {
|
"letzteBuchungen": {
|
||||||
"transactions": [
|
"transactions": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"type": "income",
|
||||||
|
"title": "Dauerauftrag",
|
||||||
|
"icon": "more-horizontal",
|
||||||
|
"color": "#6b7480",
|
||||||
|
"sign": "pos",
|
||||||
|
"amount_cents": 7500,
|
||||||
|
"currency": "EUR",
|
||||||
|
"amount_base_cents": 7500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"type": "expense",
|
||||||
|
"title": "Dauerauftrag",
|
||||||
|
"icon": "more-horizontal",
|
||||||
|
"color": "#6b7480",
|
||||||
|
"sign": "neg",
|
||||||
|
"amount_cents": 7500,
|
||||||
|
"currency": "EUR",
|
||||||
|
"amount_base_cents": 7500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"type": "expense",
|
||||||
|
"title": "Wohnen",
|
||||||
|
"icon": "home",
|
||||||
|
"color": "#3f51b5",
|
||||||
|
"sign": "neg",
|
||||||
|
"amount_cents": 1043,
|
||||||
|
"currency": "EUR",
|
||||||
|
"amount_base_cents": 1043
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"type": "expense",
|
||||||
|
"title": "Wohnen",
|
||||||
|
"icon": "home",
|
||||||
|
"color": "#3f51b5",
|
||||||
|
"sign": "neg",
|
||||||
|
"amount_cents": 1043,
|
||||||
|
"currency": "EUR",
|
||||||
|
"amount_base_cents": 1043
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"type": "expense",
|
||||||
|
"title": "Baeckerei",
|
||||||
|
"icon": "more-horizontal",
|
||||||
|
"color": "#6b7480",
|
||||||
|
"sign": "neg",
|
||||||
|
"amount_cents": 1290,
|
||||||
|
"currency": "EUR",
|
||||||
|
"amount_base_cents": 1290
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"date": "2026-10-21",
|
"date": "2026-10-21",
|
||||||
@@ -1096,32 +1318,101 @@
|
|||||||
"amount_cents": 20000,
|
"amount_cents": 20000,
|
||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
"amount_base_cents": 20000
|
"amount_base_cents": 20000
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"date": "2026-10-21",
|
|
||||||
"type": "income",
|
|
||||||
"title": "Gehalt",
|
|
||||||
"icon": "banknote",
|
|
||||||
"color": "#2e7d55",
|
|
||||||
"sign": "pos",
|
|
||||||
"amount_cents": 350000,
|
|
||||||
"currency": "EUR",
|
|
||||||
"amount_base_cents": 350000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"date": "2026-10-21",
|
|
||||||
"type": "expense",
|
|
||||||
"title": "Wohnen",
|
|
||||||
"icon": "home",
|
|
||||||
"color": "#3f51b5",
|
|
||||||
"sign": "neg",
|
|
||||||
"amount_cents": 4250,
|
|
||||||
"currency": "EUR",
|
|
||||||
"amount_base_cents": 4250
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"currency": "EUR"
|
"currency": "EUR"
|
||||||
}
|
},
|
||||||
|
"eingang": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"source": "shortcut",
|
||||||
|
"status": "booked",
|
||||||
|
"account_id": 1,
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"amount_cents": -1290,
|
||||||
|
"currency": "EUR",
|
||||||
|
"payee": "Baeckerei",
|
||||||
|
"note": null,
|
||||||
|
"category_id": null,
|
||||||
|
"transaction_id": 6,
|
||||||
|
"transfer_account_id": null,
|
||||||
|
"fx_rate": null,
|
||||||
|
"counter_amount_cents": null,
|
||||||
|
"counter_currency": null,
|
||||||
|
"created_at": "2026-10-21T19:57:08.586391"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dubletten": {
|
||||||
|
"gruppen": [
|
||||||
|
{
|
||||||
|
"account_id": 1,
|
||||||
|
"datum": "2026-10-21",
|
||||||
|
"amount_cents": 1043,
|
||||||
|
"typ": "expense",
|
||||||
|
"zeilen": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"payee": "Kiosk",
|
||||||
|
"description": null,
|
||||||
|
"category_id": 1,
|
||||||
|
"herkunft": "manuell",
|
||||||
|
"kennung": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"payee": "Kiosk",
|
||||||
|
"description": null,
|
||||||
|
"category_id": 1,
|
||||||
|
"herkunft": "manuell",
|
||||||
|
"kennung": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"uebertragsvorschlaege": {
|
||||||
|
"verknuepft": 0,
|
||||||
|
"vorschlaege": [
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"id": 9,
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"type": "expense",
|
||||||
|
"amount_cents": 7500,
|
||||||
|
"currency": "EUR",
|
||||||
|
"account_id": 1,
|
||||||
|
"account_name": "Girokonto",
|
||||||
|
"payee": "Dauerauftrag",
|
||||||
|
"description": null,
|
||||||
|
"category_id": null
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"id": 10,
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"type": "income",
|
||||||
|
"amount_cents": 7500,
|
||||||
|
"currency": "EUR",
|
||||||
|
"account_id": 3,
|
||||||
|
"account_name": "Zweitkonto",
|
||||||
|
"payee": "Dauerauftrag",
|
||||||
|
"description": null,
|
||||||
|
"category_id": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schlagwoerter_der_kategorie": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"category_id": 1,
|
||||||
|
"word": "supermarkt",
|
||||||
|
"amount_cents": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markierteSchlagwoerter": [],
|
||||||
|
"zweiterFaktor": {
|
||||||
|
"enabled": false,
|
||||||
|
"recovery_codes_left": 0
|
||||||
|
},
|
||||||
|
"bankverbindungen": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -503,7 +503,9 @@ struct ServerantwortenTests {
|
|||||||
@Test("Konten, Kategorien und Schlagwörter")
|
@Test("Konten, Kategorien und Schlagwörter")
|
||||||
func stammdaten() throws {
|
func stammdaten() throws {
|
||||||
let konten = try lies([Konto].self, "konten")
|
let konten = try lies([Konto].self, "konten")
|
||||||
#expect(konten.count == 2)
|
// Nicht auf eine genaue Zahl pruefen: Die Pruefdatei waechst mit
|
||||||
|
// jeder Stufe, und ein Test, der dann rot wird, sagt nichts aus.
|
||||||
|
#expect(konten.count >= 2)
|
||||||
#expect(konten.contains { $0.isLocked })
|
#expect(konten.contains { $0.isLocked })
|
||||||
|
|
||||||
let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen")
|
let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen")
|
||||||
@@ -515,7 +517,7 @@ struct ServerantwortenTests {
|
|||||||
@Test("Buchungen samt Tags, Übertrag und Vertragszuordnung")
|
@Test("Buchungen samt Tags, Übertrag und Vertragszuordnung")
|
||||||
func buchungen() throws {
|
func buchungen() throws {
|
||||||
let alle = try lies([Buchung].self, "buchungen")
|
let alle = try lies([Buchung].self, "buchungen")
|
||||||
#expect(alle.count == 5)
|
#expect(alle.count >= 5)
|
||||||
// Jede Spielart einmal – sonst prüft die Datei die Felder nicht, die
|
// Jede Spielart einmal – sonst prüft die Datei die Felder nicht, die
|
||||||
// nur in einem Fall belegt sind.
|
// nur in einem Fall belegt sind.
|
||||||
#expect(alle.contains { !$0.tagIds.isEmpty })
|
#expect(alle.contains { !$0.tagIds.isEmpty })
|
||||||
@@ -531,9 +533,12 @@ struct ServerantwortenTests {
|
|||||||
|
|
||||||
let alle = try lies([Buchung].self, "buchungen")
|
let alle = try lies([Buchung].self, "buchungen")
|
||||||
let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare)
|
let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare)
|
||||||
// Fünf Buchungen, zwei davon ein Paar: vier Zeilen.
|
// Die Paarliste fuehrt jede Verknuepfung zweimal auf, einmal je
|
||||||
#expect(zeilen.count == 4)
|
// Richtung - also halb so viele Paare wie Eintraege. Genau so viele
|
||||||
#expect(zeilen.filter(\.istUebertrag).count == 2)
|
// 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")
|
@Test("Budget-Monat, Vorlage und Verträge")
|
||||||
@@ -557,7 +562,7 @@ struct ServerantwortenTests {
|
|||||||
@Test("Alle vier Dashboard-Karten")
|
@Test("Alle vier Dashboard-Karten")
|
||||||
func dashboard() throws {
|
func dashboard() throws {
|
||||||
let staende = try lies(Kontostaende.self, "kontostaende")
|
let staende = try lies(Kontostaende.self, "kontostaende")
|
||||||
#expect(staende.accounts.count == 2)
|
#expect(staende.accounts.count >= 2)
|
||||||
#expect(staende.gebunden != 0)
|
#expect(staende.gebunden != 0)
|
||||||
|
|
||||||
#expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty)
|
#expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty)
|
||||||
@@ -642,3 +647,174 @@ struct EingabenTests {
|
|||||||
#expect(Zahlungsrhythmus.allCases.map(\.rawValue) == [1, 2, 3, 6, 12])
|
#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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,12 +11,16 @@
|
|||||||
"allgemein.unbekannt" = "Unbekannt";
|
"allgemein.unbekannt" = "Unbekannt";
|
||||||
"allgemein.sichern" = "Sichern";
|
"allgemein.sichern" = "Sichern";
|
||||||
"allgemein.ok" = "OK";
|
"allgemein.ok" = "OK";
|
||||||
|
"allgemein.fertig" = "Fertig";
|
||||||
|
"allgemein.bearbeiten" = "Bearbeiten";
|
||||||
|
"allgemein.loeschen" = "Löschen";
|
||||||
|
|
||||||
/* --- Reiter --- */
|
/* --- Reiter --- */
|
||||||
"reiter.dashboard" = "Übersicht";
|
"reiter.dashboard" = "Übersicht";
|
||||||
"reiter.budget" = "Budget";
|
"reiter.budget" = "Budget";
|
||||||
"reiter.buchungen" = "Buchungen";
|
"reiter.buchungen" = "Buchungen";
|
||||||
"reiter.abos" = "Verträge";
|
"reiter.abos" = "Verträge";
|
||||||
|
"reiter.arbeitsliste" = "Eingang";
|
||||||
"reiter.konten" = "Konten";
|
"reiter.konten" = "Konten";
|
||||||
"reiter.einstellungen" = "Einstellungen";
|
"reiter.einstellungen" = "Einstellungen";
|
||||||
|
|
||||||
@@ -239,3 +243,135 @@
|
|||||||
"konto.loeschen" = "Konto löschen";
|
"konto.loeschen" = "Konto löschen";
|
||||||
"konto.loeschenFrage" = "Dieses Konto löschen?";
|
"konto.loeschenFrage" = "Dieses Konto löschen?";
|
||||||
"konto.loeschenErklaerung" = "Das geht nur, wenn keine Buchung mehr daran hängt. Sonst archivieren.";
|
"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";
|
||||||
|
|||||||
@@ -11,12 +11,16 @@
|
|||||||
"allgemein.unbekannt" = "Unknown";
|
"allgemein.unbekannt" = "Unknown";
|
||||||
"allgemein.sichern" = "Save";
|
"allgemein.sichern" = "Save";
|
||||||
"allgemein.ok" = "OK";
|
"allgemein.ok" = "OK";
|
||||||
|
"allgemein.fertig" = "Done";
|
||||||
|
"allgemein.bearbeiten" = "Edit";
|
||||||
|
"allgemein.loeschen" = "Delete";
|
||||||
|
|
||||||
/* --- Tabs --- */
|
/* --- Tabs --- */
|
||||||
"reiter.dashboard" = "Overview";
|
"reiter.dashboard" = "Overview";
|
||||||
"reiter.budget" = "Budget";
|
"reiter.budget" = "Budget";
|
||||||
"reiter.buchungen" = "Transactions";
|
"reiter.buchungen" = "Transactions";
|
||||||
"reiter.abos" = "Subscriptions";
|
"reiter.abos" = "Subscriptions";
|
||||||
|
"reiter.arbeitsliste" = "Inbox";
|
||||||
"reiter.konten" = "Accounts";
|
"reiter.konten" = "Accounts";
|
||||||
"reiter.einstellungen" = "Settings";
|
"reiter.einstellungen" = "Settings";
|
||||||
|
|
||||||
@@ -239,3 +243,135 @@
|
|||||||
"konto.loeschen" = "Delete account";
|
"konto.loeschen" = "Delete account";
|
||||||
"konto.loeschenFrage" = "Delete this account?";
|
"konto.loeschenFrage" = "Delete this account?";
|
||||||
"konto.loeschenErklaerung" = "Only possible while no transaction is attached. Archive it otherwise.";
|
"konto.loeschenErklaerung" = "Only possible while no transaction is attached. Archive it otherwise.";
|
||||||
|
|
||||||
|
/* --- Inbox / work list --- */
|
||||||
|
"arbeit.leerTitel" = "Nothing to do";
|
||||||
|
"arbeit.leerErklaerung" = "No pending entries, no transfer suggestions, no duplicates. Just as it should be.";
|
||||||
|
"arbeit.eingang" = "To book";
|
||||||
|
"arbeit.uebertraege" = "Might be transfers";
|
||||||
|
"arbeit.ohneKategorie" = "Without a category";
|
||||||
|
"arbeit.dubletten" = "Looks duplicated";
|
||||||
|
"arbeit.dublettenErklaerung" = "Same account, same day, same amount. Only you know whether it really is the same payment - different reference numbers mean the bank reported them as separate.";
|
||||||
|
"arbeit.buchen" = "Book";
|
||||||
|
"arbeit.alleBuchen" = "Book all that have an account";
|
||||||
|
"arbeit.alsUebertrag" = "Transfer";
|
||||||
|
"arbeit.verwerfen" = "Discard";
|
||||||
|
"arbeit.verbinden" = "Link";
|
||||||
|
"arbeit.keinUebertrag" = "Not a transfer";
|
||||||
|
"arbeit.istRichtigSo" = "That is correct";
|
||||||
|
"arbeit.unklarHinweis" = "The matcher was unsure - have a look first.";
|
||||||
|
"arbeit.hatKategorie" = "One of the two has a category. Linking takes it out of the budget.";
|
||||||
|
"arbeit.ohneKonto" = "no account";
|
||||||
|
"arbeit.rueckfrage" = "Are you sure?";
|
||||||
|
"arbeit.trotzdem" = "Do it anyway";
|
||||||
|
"arbeit.zielkontoErklaerung" = "This creates one transaction moving the money from one account to the other. It counts in both balances and in no report.";
|
||||||
|
|
||||||
|
/* --- Inbox: source and status --- */
|
||||||
|
"eingang.quelle.kurzbefehl" = "Shortcut";
|
||||||
|
"eingang.quelle.postfach" = "Mailbox";
|
||||||
|
"eingang.quelle.bank" = "Bank";
|
||||||
|
"eingang.stand.gebucht" = "booked";
|
||||||
|
"eingang.stand.offen" = "pending";
|
||||||
|
"eingang.stand.unklar" = "unclear";
|
||||||
|
"eingang.stand.verworfen" = "discarded";
|
||||||
|
|
||||||
|
/* --- Manage --- */
|
||||||
|
"nebenraeume.titel" = "Manage";
|
||||||
|
"nebenraeume.kategorien" = "Categories";
|
||||||
|
"nebenraeume.tags" = "Tags";
|
||||||
|
"nebenraeume.bank" = "Bank connection";
|
||||||
|
|
||||||
|
/* --- Categories --- */
|
||||||
|
"kategorien.leer" = "No categories yet.";
|
||||||
|
"kategorien.neu" = "New category";
|
||||||
|
"kategorien.aendern" = "Edit category";
|
||||||
|
"kategorien.neueGruppe" = "New group";
|
||||||
|
"kategorien.gruppe" = "Group";
|
||||||
|
"kategorien.art" = "Kind";
|
||||||
|
"kategorien.ausgaben" = "Expenses";
|
||||||
|
"kategorien.einnahmen" = "Income";
|
||||||
|
"kategorien.archivierteZeigen" = "Show archived";
|
||||||
|
"kategorien.sparen" = "saving";
|
||||||
|
"kategorien.ohneAuswertung" = "not counted";
|
||||||
|
"kategorien.istSparen" = "Spending that is not a loss";
|
||||||
|
"kategorien.istSparenErklaerung" = "For savings and reserves. Spending more than planned is no warning here - it is good news.";
|
||||||
|
"kategorien.ohneAuswertungSchalter" = "Keep out of reports";
|
||||||
|
"kategorien.ohneAuswertungErklaerung" = "For pass-through items. The transactions stay but count in no chart and no total.";
|
||||||
|
"kategorien.loeschen" = "Delete category";
|
||||||
|
"kategorien.loeschenFrage" = "Delete this category?";
|
||||||
|
"kategorien.loeschenErklaerung" = "Only possible while no transaction is attached.";
|
||||||
|
"kategorien.archivierenStattLoeschen" = "Archiving hides the category and keeps its transactions. Deleting only works while none are attached.";
|
||||||
|
"kategorien.gruppeLoeschen" = "Delete group";
|
||||||
|
"kategorien.gruppeLoeschenFrage" = "Delete this group?";
|
||||||
|
"kategorien.gruppeLoeschenErklaerung" = "Only possible while no category is left in it.";
|
||||||
|
"kategorien.erkennungswoerter" = "Match words";
|
||||||
|
"kategorien.wortHinzufuegen" = "Add a word";
|
||||||
|
"kategorien.erkennungErklaerung" = "If one of these words appears in the payee or description, the importer assigns this category. The longest word wins. Applies to future imports only - existing transactions stay as they are.";
|
||||||
|
"kategorien.markiertTitel" = "Words that get corrected a lot";
|
||||||
|
"kategorien.markiertErklaerung" = "These words often assign the wrong category. Keep them if those were one-offs - remove them if the word does not work.";
|
||||||
|
"kategorien.markiertZaehler" = "applied %1$d times, corrected %2$d times";
|
||||||
|
"kategorien.behalten" = "Keep";
|
||||||
|
"kategorien.entfernen" = "Remove";
|
||||||
|
|
||||||
|
/* --- Tags --- */
|
||||||
|
"tags.neu" = "New tag";
|
||||||
|
"tags.erklaerung" = "Tags sit across categories: one transaction can carry several. Good for everything that does not fit a category - Iceland trip, tax deductible, to be reimbursed.";
|
||||||
|
|
||||||
|
/* --- Settings --- */
|
||||||
|
"einstellungen.erscheinungsbild" = "Appearance";
|
||||||
|
"einstellungen.system" = "System";
|
||||||
|
"einstellungen.hell" = "Light";
|
||||||
|
"einstellungen.dunkel" = "Dark";
|
||||||
|
"einstellungen.serverweitErklaerung" = "These settings belong to your account, not just this device - the browser follows them too.";
|
||||||
|
|
||||||
|
/* --- Two-factor setup --- */
|
||||||
|
"zweifaktor.stand" = "Two-factor";
|
||||||
|
"zweifaktor.aktiv" = "Active";
|
||||||
|
"zweifaktor.inaktiv" = "Not set up";
|
||||||
|
"zweifaktor.aktivErklaerung" = "Signing in through the browser asks for a code as well. Turning it off only works on the desktop - it needs your password, and this app deliberately stores none.";
|
||||||
|
"zweifaktor.inaktivErklaerung" = "A second factor protects your account even if someone knows your password.";
|
||||||
|
"zweifaktor.einrichten" = "Set up";
|
||||||
|
"zweifaktor.schrittEins" = "Step 1: add it to your authenticator app";
|
||||||
|
"zweifaktor.schrittZwei" = "Step 2: confirm with a code";
|
||||||
|
"zweifaktor.inAppOeffnen" = "Open in authenticator app";
|
||||||
|
"zweifaktor.geheimnis" = "Secret";
|
||||||
|
"zweifaktor.geheimnisErklaerung" = "The button opens the app directly. If that does not work, type the secret in by hand.";
|
||||||
|
"zweifaktor.code" = "Six-digit code";
|
||||||
|
"zweifaktor.aktivieren" = "Turn on";
|
||||||
|
"zweifaktor.rettungscodes" = "Recovery codes";
|
||||||
|
"zweifaktor.rettungscodesErklaerung" = "Each code works once instead of an app code. They appear here and nowhere else - save them now.";
|
||||||
|
"zweifaktor.codesSichern" = "Save codes";
|
||||||
|
"zweifaktor.codesNotiert" = "I have saved them";
|
||||||
|
|
||||||
|
/* --- Bank --- */
|
||||||
|
"bank.leerTitel" = "No bank connection";
|
||||||
|
"bank.leerErklaerung" = "Connecting a bank only works on the desktop - the bank redirects there. You can fetch from here afterwards.";
|
||||||
|
"bank.land" = "Country";
|
||||||
|
"bank.zuletzt" = "Last fetched";
|
||||||
|
"bank.nie" = "never";
|
||||||
|
"bank.gueltigBis" = "Valid until";
|
||||||
|
"bank.takt" = "Automatically";
|
||||||
|
"bank.alleNMinuten" = "every %d minutes";
|
||||||
|
"bank.abrufen" = "Fetch now";
|
||||||
|
"bank.saldenAuffrischen" = "Refresh balances only";
|
||||||
|
"bank.saldenAufgefrischt" = "Balances refreshed.";
|
||||||
|
"bank.abrufFertig" = "Fetch complete";
|
||||||
|
"bank.abrufErgebnis" = "Fetched %1$d transactions, %2$d of them new.";
|
||||||
|
"bank.ohneZuordnung" = "This connection has no account assigned yet. That is done on the desktop.";
|
||||||
|
|
||||||
|
/* Screen lock */
|
||||||
|
"sperre.titel" = "Certain is locked";
|
||||||
|
"sperre.pin" = "PIN";
|
||||||
|
"sperre.faceid" = "Unlock with Face ID";
|
||||||
|
"sperre.touchid" = "Unlock with Touch ID";
|
||||||
|
"sperre.grund" = "Unlocks Certain without typing your PIN.";
|
||||||
|
"sperre.falsch" = "Wrong PIN. %d attempts left, then the app signs out.";
|
||||||
|
"sperre.abmelden" = "Forgot your PIN? Sign out";
|
||||||
|
"pin.einrichtenTitel" = "Set a PIN";
|
||||||
|
"pin.erklaerung" = "Six digits. You need them when Face ID does not work – and on every start.";
|
||||||
|
"pin.neu" = "New PIN";
|
||||||
|
"pin.wiederholen" = "Again";
|
||||||
|
"pin.zuKurz" = "The PIN must be six digits.";
|
||||||
|
"pin.ungleich" = "The two entries differ.";
|
||||||
|
"pin.sichern" = "Set PIN";
|
||||||
|
"pin.aendern" = "Change PIN";
|
||||||
|
|||||||
+96
-21
@@ -6,7 +6,7 @@ haben: was entschieden ist, was fertig ist, was als Nächstes dran ist, und was
|
|||||||
ich über das Backend herausgefunden habe. Lies sie ganz, bevor du anfängst —
|
ich über das Backend herausgefunden habe. Lies sie ganz, bevor du anfängst —
|
||||||
die Abschnitte „Was das Backend vorgibt" und „Fallen" ersparen dir Tage.
|
die Abschnitte „Was das Backend vorgibt" und „Fallen" ersparen dir Tage.
|
||||||
|
|
||||||
Stand: 21.10.2026, Commit `82db09e`. Alles committet und gepusht.
|
Stand: 21.10.2026, Commit `d290930`. Alles committet und gepusht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ Drei Tests in `test_budget.py` halten beide Richtungen und die Null fest.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Was fertig ist — Stufen 1 bis 3 ✅
|
## 5. Was fertig ist — Stufen 1 bis 5 ✅
|
||||||
|
|
||||||
### Stufe 1 (Commit `6b86a6b`)
|
### Stufe 1 (Commit `6b86a6b`)
|
||||||
|
|
||||||
@@ -397,25 +397,101 @@ Gibt er nichts aus, ist nichts offen. (Ein Schlüssel, der nur in einem
|
|||||||
Lücke des Befehls.) Dass beide Sprachfassungen dieselben Schlüssel tragen,
|
Lücke des Befehls.) Dass beide Sprachfassungen dieselben Schlüssel tragen,
|
||||||
prüft ein `diff` der beiden `grep`-Ausgaben.
|
prüft ein `diff` der beiden `grep`-Ausgaben.
|
||||||
|
|
||||||
|
### Stufe 4 — Arbeitsliste ✅ (Commit `9edf610`)
|
||||||
|
|
||||||
|
Ein Reiter „Eingang" mit vier Abschnitten, jeder mit Anzahl im Kopf:
|
||||||
|
offene Eingangseinträge, Übertragsvorschläge, Gebuchtes ohne Kategorie,
|
||||||
|
Dubletten. `Arbeitsliste/ArbeitslisteAnsicht.swift`, dazu zwei schmale
|
||||||
|
Auswahlblätter in `AuswahlBlaetter.swift`.
|
||||||
|
|
||||||
|
**Alles in einem Abruf** (`Arbeitsstand`), aus demselben Grund wie beim
|
||||||
|
Dashboard: Vier getrennte Zwischenspeicher könnten offline aus vier
|
||||||
|
verschiedenen Momenten stammen.
|
||||||
|
|
||||||
|
#### Der 409-Fall sitzt hier, und nur hier
|
||||||
|
|
||||||
|
`POST /inbox/{id}/transfer` kann mit 409 antworten. Das ist eine **Frage**,
|
||||||
|
kein Fehler: Die App zeigt den Hinweis und wiederholt den Aufruf auf Wunsch
|
||||||
|
mit `trotzdem: true`. `bucheAlsUebertrag` fängt `APIFehler.konflikt` eigens
|
||||||
|
ab, statt ihn in den allgemeinen Fehlerweg laufen zu lassen. Wer später einen
|
||||||
|
weiteren 409-Endpunkt anbindet, macht es genauso.
|
||||||
|
|
||||||
|
#### Kleinigkeiten, die man sonst zweimal findet
|
||||||
|
|
||||||
|
- **Ohne Konto lässt sich nichts buchen** (400 vom Backend). Der Knopf bleibt
|
||||||
|
grau — und die Zeile sagt „ohne Konto", damit das nicht unerklärt wirkt.
|
||||||
|
- **Ein Übertrag braucht keine Kategorie.** Daran erkennt man ihn:
|
||||||
|
`transfer_account_id` ist gesetzt. Ohne diese Prüfung fragte die Liste ewig
|
||||||
|
nach einer Kategorie, die es nie geben wird.
|
||||||
|
- **Die Dublettenliste zeigt die Kennung der Bank.** Verschiedene Kennungen
|
||||||
|
heissen: Die Bank hat sie ausdrücklich als verschiedene Zahlungen gemeldet.
|
||||||
|
Ohne diese Spalte lässt sich die Frage nicht entscheiden.
|
||||||
|
|
||||||
|
#### Ein Backend-Fehler, den die App ans Licht brachte
|
||||||
|
|
||||||
|
`/uebertraege/vorschlaege` lieferte jedes Paar **zweimal**, einmal je
|
||||||
|
Richtung: Was verknüpft wird, fällt über `verbraucht` heraus — ein blosser
|
||||||
|
Vorschlag nicht. Die Weboberfläche zeigte den Doppeleintrag genauso.
|
||||||
|
Behoben in `services/uebertraege.py` (`schlage_vor`), mit Test.
|
||||||
|
|
||||||
|
### Stufe 5 — Nebenräume ✅ (Commit `d290930`)
|
||||||
|
|
||||||
|
Kategorien, Schlagwörter, Bankabruf und der zweite Faktor — alle unter
|
||||||
|
**Einstellungen**, nicht als eigene Reiter. Man braucht sie selten, und eine
|
||||||
|
Leiste mit zehn Reitern findet niemand mehr. `Nebenraeume/`.
|
||||||
|
|
||||||
|
#### Was hier bewusst nicht geht
|
||||||
|
|
||||||
|
- **Den zweiten Faktor abschalten.** Es verlangt das Kontopasswort, und die
|
||||||
|
App speichert keines. Ein Vollzugang auf diesem Gerät soll den Schutz des
|
||||||
|
Kontos nicht aufheben können. Dasselbe gilt für neue Rettungscodes.
|
||||||
|
- **Eine Bank verbinden.** Die Bank leitet fest auf `{base_url}/bank/rueckkehr`
|
||||||
|
zurück; der Weg führt zwangsläufig über den Browser. Abrufen und Salden
|
||||||
|
auffrischen gehen von hier.
|
||||||
|
|
||||||
|
#### Zwei Eigenheiten, die beim Nachbauen zählen
|
||||||
|
|
||||||
|
1. **Der zweite Faktor braucht auf dem Telefon keinen QR-Code.** Die
|
||||||
|
Authenticator-App liegt daneben — `otpauth_uri` führt per Link direkt
|
||||||
|
hinein. `qr_png` bleibt ungenutzt; auf dem iPad könnte man es zeigen.
|
||||||
|
2. **Die Rettungscodes erscheinen genau einmal.** Das Blatt lässt sich nicht
|
||||||
|
wegwischen (`interactiveDismissDisabled`) und erst schliessen, wenn der
|
||||||
|
Nutzer bestätigt hat, sie gesichert zu haben. Wer es verliert, braucht
|
||||||
|
das Kontopasswort am Rechner.
|
||||||
|
|
||||||
|
#### Einstellungen schreiben ohne Sichern-Knopf
|
||||||
|
|
||||||
|
`PUT /settings/{key}` nimmt je Aufruf **einen** Wert, als Query. Die Bindung
|
||||||
|
schreibt beim Setzen direkt; ein Formular mit Sichern-Knopf wäre eine
|
||||||
|
Zwischenstufe, die nichts bringt. Die Werte gehören dem **Konto**, nicht dem
|
||||||
|
Gerät — das steht auch unter dem Abschnitt, sonst wundert sich jemand, warum
|
||||||
|
der Browser sich mitändert.
|
||||||
|
|
||||||
|
#### Prüfdatei-Tests zählen keine Mengen mehr
|
||||||
|
|
||||||
|
Die Saat in `erzeuge_ios_fixture.py` wuchs um Eingangseintrag, Dubletten und
|
||||||
|
ein Übertragspaar — und die älteren Tests wurden rot, weil sie exakte Anzahlen
|
||||||
|
prüften. Sie prüfen jetzt Eigenschaften (`>= 2`, „enthält einen Übertrag"),
|
||||||
|
damit sie das nächste Wachstum überleben. **Daran denken, wenn du die Saat
|
||||||
|
erweiterst.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Was als Nächstes dran ist
|
## 6. Was als Nächstes dran ist
|
||||||
|
|
||||||
**Stufe 4 ist dran.** Alle vier Backend-Ergänzungen sind drin; von hier an
|
**Stufe 6 ist dran.** Alle vier Backend-Ergänzungen sind drin; von hier an
|
||||||
reicht die API, wie sie ist.
|
reicht die API, wie sie ist.
|
||||||
|
|
||||||
Was aus Stufe 3 bewusst nicht mitkam: das **Logo** eines Vertrags (Upload und
|
Was bewusst nicht mitkam: das **Logo** eines Vertrags (Upload und Zuschnitt,
|
||||||
Zuschnitt, `POST /subscriptions/{id}/logo`) und die **Reihenfolge** der Konten
|
`POST /subscriptions/{id}/logo`), die **Reihenfolge** der Konten
|
||||||
(`PUT /accounts/order`). Beides ist am Rechner bequemer und hielt niemanden
|
(`PUT /accounts/order`), das **Lösen** einer Übertrags-Verknüpfung
|
||||||
auf.
|
(`DELETE /uebertraege/{link_id}`), die **Transfer-Schlagwörter**
|
||||||
|
(`/categories/transfer-keywords`) und das **Anwenden** von Regeln auf
|
||||||
|
Bestehendes (`POST /categories/keywords/anwenden`). Alles am Rechner
|
||||||
|
bequemer, nichts davon hielt jemanden auf.
|
||||||
|
|
||||||
**Stufe 4 — Arbeitsliste.** Eingang mit Buchen, Verwerfen, Kategorie zuordnen,
|
Weiter aussen vor, wie von Anfang an entschieden: Bank **verbinden**,
|
||||||
Übertragsvorschlägen und Dubletten. Fachlich der Teil, der am besten aufs
|
Postfach einrichten, Sicherung und Wiederherstellung, Benutzerverwaltung.
|
||||||
Telefon gehört: eine Zeile, eine Entscheidung.
|
|
||||||
|
|
||||||
**Stufe 5 — Nebenräume.** Kategorien, Tags, Schlagwörter, Einstellungen,
|
|
||||||
Bankabruf anstossen. Hier gehört auch das **Einrichten** des zweiten Faktors
|
|
||||||
hin — bisher verweist die App dafür in den Browser.
|
|
||||||
|
|
||||||
**Stufe 6 — Erweiterungen.** Teilen-Menü und Kurzbefehle. Brauchen eigene Ziele
|
**Stufe 6 — Erweiterungen.** Teilen-Menü und Kurzbefehle. Brauchen eigene Ziele
|
||||||
im Xcode-Projekt, eine **App-Gruppe** und eine **geteilte
|
im Xcode-Projekt, eine **App-Gruppe** und eine **geteilte
|
||||||
@@ -431,14 +507,13 @@ Vorlagen, Verlauf mit Rücknahme.
|
|||||||
### Zum Umfang, ehrlich
|
### Zum Umfang, ehrlich
|
||||||
|
|
||||||
Die Weboberfläche hat rund 8900 Zeilen JSX. Nativ wird das nicht weniger.
|
Die Weboberfläche hat rund 8900 Zeilen JSX. Nativ wird das nicht weniger.
|
||||||
Stufe 2 hat rund 1800 Zeilen gebraucht, Stufe 3 rund 1200; Stufen 4 bis 6
|
Stufe 2 hat rund 1800 Zeilen gebraucht, Stufe 3 rund 1200, Stufe 4 rund 900,
|
||||||
zusammen sind grob ein bis zwei Wochen, Stufe 7 nochmal zwei bis drei.
|
Stufe 5 rund 1500; Stufe 6 ist überschaubar, Stufe 7 nochmal zwei bis drei
|
||||||
|
Wochen.
|
||||||
|
|
||||||
Der **409-Fall** („nachfragen", mit `trotzdem=true` wiederholbar) kam in
|
Der **409-Fall** („nachfragen", mit `trotzdem=true` wiederholbar) ist in
|
||||||
Stufe 3 nicht vor — er steckt nur an
|
Stufe 4 angekommen und erledigt — siehe dort. In den Stufen 5 bis 7 kommt er
|
||||||
`POST /transactions/{id}/transfer`, also in Stufe 4. Dort wird er gebraucht:
|
nicht mehr vor.
|
||||||
Die App muss den Hinweis zeigen und den Aufruf auf Wunsch wiederholen,
|
|
||||||
statt den Fehler bloss zu melden.
|
|
||||||
|
|
||||||
Stufe 7 steht bewusst am Ende. Alles davor ist für sich nutzbar. Der Ablauf
|
Stufe 7 steht bewusst am Ende. Alles davor ist für sich nutzbar. Der Ablauf
|
||||||
hat **keinen Server-Zustand** zwischen den Schritten: Die Datei wird bei jedem
|
hat **keinen Server-Zustand** zwischen den Schritten: Die Datei wird bei jedem
|
||||||
|
|||||||
+19
-3
@@ -20,6 +20,7 @@ targets:
|
|||||||
- path: Anmeldung
|
- path: Anmeldung
|
||||||
- path: App
|
- path: App
|
||||||
- path: Abos
|
- path: Abos
|
||||||
|
- path: Arbeitsliste
|
||||||
- path: Bausteine
|
- path: Bausteine
|
||||||
- path: Buchungen
|
- path: Buchungen
|
||||||
- path: Budget
|
- path: Budget
|
||||||
@@ -27,20 +28,32 @@ targets:
|
|||||||
- path: Einstellungen
|
- path: Einstellungen
|
||||||
- path: Konten
|
- path: Konten
|
||||||
- path: Modelle
|
- path: Modelle
|
||||||
|
- path: Nebenraeume
|
||||||
- path: Netzwerk
|
- path: Netzwerk
|
||||||
# Deutsch und Englisch, zeilengleich gehalten.
|
# Deutsch und Englisch, zeilengleich gehalten.
|
||||||
- path: Texte
|
- path: Texte
|
||||||
|
# Icon-Composer-Buendel (Ordner). Als Datei einhaengen, sonst zerlegt
|
||||||
|
# XcodeGen es in eine Gruppe und actool findet das Icon nicht.
|
||||||
|
- path: AppIcon.icon
|
||||||
|
type: file
|
||||||
|
buildPhase: resources
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.certain
|
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.certain
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
# Signing automatisch; wer mit einem anderen Apple-Konto baut, traegt
|
# Signing automatisch; wer mit einem anderen Apple-Konto baut, traegt
|
||||||
# hier seine Team-ID ein (Xcode: Signing & Capabilities -> Team).
|
# hier seine Team-ID ein (Xcode: Signing & Capabilities -> Team).
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
DEVELOPMENT_TEAM: PP34X97WS3
|
DEVELOPMENT_TEAM: PP34X97WS3
|
||||||
MARKETING_VERSION: "0.1"
|
MARKETING_VERSION: "0.1"
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
# iPhone und iPad, zweispaltig gedacht.
|
# Nur iPhone, nur Hochformat - am Rechner gibt es die Weboberflaeche.
|
||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
|
SUPPORTED_PLATFORMS: "iphoneos iphonesimulator"
|
||||||
|
SUPPORTS_MACCATALYST: NO
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.finance"
|
||||||
# Sprachfassung 5: der Code ist nicht auf die strikte
|
# Sprachfassung 5: der Code ist nicht auf die strikte
|
||||||
# Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen,
|
# Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen,
|
||||||
# wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind.
|
# wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind.
|
||||||
@@ -50,7 +63,10 @@ targets:
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName: Certain
|
INFOPLIST_KEY_CFBundleDisplayName: Certain
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait"
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"
|
||||||
|
# Ohne diesen Text stuerzt die App beim ersten Face-ID-Versuch ab.
|
||||||
|
INFOPLIST_KEY_NSFaceIDUsageDescription: "Damit entsperrst du Certain, ohne deine PIN zu tippen."
|
||||||
|
|
||||||
CertainTests:
|
CertainTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export default function Categories() {
|
|||||||
editing,
|
editing,
|
||||||
form: editing
|
form: editing
|
||||||
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
|
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
|
||||||
: { name: "", kind: "expense", icon: "list", color: "#3f51b5" },
|
: { name: "", kind: "expense", icon: "list", color: "#3f9c72" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function openCategory(groupId, editing = null) {
|
function openCategory(groupId, editing = null) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useToast } from "../toast";
|
|||||||
//
|
//
|
||||||
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
|
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
|
||||||
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
|
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
|
||||||
const STANDARDFARBE = "#3f51b5";
|
const STANDARDFARBE = "#3f9c72";
|
||||||
|
|
||||||
export default function Tags() {
|
export default function Tags() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
Reference in New Issue
Block a user