Compare commits
10
Commits
6c6257b8f9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ec2dc5fd | ||
|
|
4e7f572d93 | ||
|
|
2d1d571b17 | ||
|
|
a079bf406c | ||
|
|
929a735699 | ||
|
|
9195906c2b | ||
|
|
f918297742 | ||
|
|
37517f360b | ||
|
|
d290930759 | ||
|
|
bab5cc5060 |
@@ -409,7 +409,7 @@ class CategoryGroup(Base):
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -431,7 +431,7 @@ class Category(Base):
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
@@ -1312,7 +1312,7 @@ class Tag(Base):
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
@@ -361,7 +361,7 @@ class TagOut(BaseModel):
|
||||
class TagCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=60)
|
||||
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
||||
color: str = "#6b7480"
|
||||
color: str = "#3f9c72"
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
@@ -406,7 +406,7 @@ class CategoryGroupCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
kind: CategoryKind = CategoryKind.expense
|
||||
icon: str = "list"
|
||||
color: str = "#6b7480"
|
||||
color: str = "#3f9c72"
|
||||
|
||||
|
||||
class CategoryGroupUpdate(BaseModel):
|
||||
@@ -420,7 +420,7 @@ class CategoryCreate(BaseModel):
|
||||
group_id: int
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
icon: str = "more-horizontal"
|
||||
color: str = "#6b7480"
|
||||
color: str = "#3f9c72"
|
||||
excluded_from_stats: bool = False
|
||||
spending_is_good: bool = False
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ from app.main import app # noqa: E402
|
||||
ZIEL = Path(__file__).resolve().parents[2] / "ios" / "Tests" / "Fixtures" / "antworten.json"
|
||||
|
||||
|
||||
def _seed(c: TestClient) -> None:
|
||||
def _seed(c: TestClient) -> dict:
|
||||
"""Genug Daten, dass jedes Feld einmal belegt ist.
|
||||
|
||||
Leere Listen pruefen nichts: Ein falscher Feldname in einer Zeile faellt
|
||||
@@ -122,27 +122,44 @@ def _seed(c: TestClient) -> None:
|
||||
"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"})
|
||||
|
||||
PFADE = {
|
||||
"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",
|
||||
}
|
||||
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:
|
||||
@@ -152,10 +169,10 @@ def main() -> int:
|
||||
if r.status_code != 200:
|
||||
print("Anmeldung fehlgeschlagen:", r.text)
|
||||
return 1
|
||||
_seed(c)
|
||||
ids = _seed(c)
|
||||
|
||||
heraus = {}
|
||||
for name, pfad in PFADE.items():
|
||||
for name, pfad in pfade(ids).items():
|
||||
antwort = c.get(pfad)
|
||||
if antwort.status_code != 200:
|
||||
print(f"{pfad} antwortet {antwort.status_code}: {antwort.text[:200]}")
|
||||
|
||||
+4
-2
@@ -55,9 +55,11 @@ Pflicht-Passwortwechsel, Kontenliste. Stufe 2 ✅: Übersicht, Budget-Monat,
|
||||
Buchungsliste mit Suche, Vertragsliste. Stufe 3 ✅: Buchungen, Budget-Zeilen,
|
||||
Verträge und Konten anlegen, ändern und löschen. Stufe 4 ✅: Arbeitsliste mit
|
||||
Eingang, Übertragsvorschlägen, Gebuchtem ohne Kategorie und Dubletten.
|
||||
Übersetzt fehlerfrei, 54 Tests grün, läuft im Simulator; das Xcode-Projekt
|
||||
Stufe 5 ✅: Kategorien samt Erkennungswörtern, Schlagwörter, Einstellungen,
|
||||
Bankabruf und das Einrichten des zweiten Faktors.
|
||||
Übersetzt fehlerfrei, 59 Tests grün, läuft im Simulator; das Xcode-Projekt
|
||||
entsteht aus `ios/project.yml`, die Modelle werden gegen echte
|
||||
Serverantworten geprüft.
|
||||
Stufen 5–7 (Nebenräume, Erweiterungen, voller CSV-Import) stehen aus.
|
||||
Stufen 6–7 (Teilen-Menü und Kurzbefehle, voller CSV-Import) stehen aus.
|
||||
|
||||
**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.
|
||||
private(set) var beschaeftigt = false
|
||||
|
||||
// MARK: - Bildschirmsperre
|
||||
|
||||
/// Liegt der Sperrbildschirm über der App?
|
||||
private(set) var gesperrt = false
|
||||
/// Ob eine PIN eingerichtet ist. Als Eigenschaft und nicht direkt aus der
|
||||
/// Ablage gelesen, damit die Ansicht nach dem Einrichten neu zeichnet.
|
||||
private(set) var hatPIN = Ablage.hatPIN
|
||||
private var seitWannWeg: Date?
|
||||
private var fehlversuche = 0
|
||||
|
||||
/// So lange darf man weg sein, ohne erneut zu entsperren.
|
||||
private static let gnadenfrist: TimeInterval = 60
|
||||
/// Danach fliegt die Marke raus und es geht zur Anmeldung. Ohne Grenze
|
||||
/// wäre eine sechsstellige PIN in Ruhe durchprobierbar.
|
||||
private static let maxFehlversuche = 5
|
||||
|
||||
var serverAdresse: URL? { Ablage.serverAdresse }
|
||||
|
||||
// MARK: - Start
|
||||
@@ -54,6 +70,9 @@ final class AppZustand {
|
||||
}
|
||||
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
|
||||
client = neuer
|
||||
// Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm.
|
||||
hatPIN = Ablage.hatPIN
|
||||
gesperrt = hatPIN
|
||||
guard Ablage.marke != nil else {
|
||||
lage = .brauchtAnmeldung
|
||||
return
|
||||
@@ -237,6 +256,9 @@ final class AppZustand {
|
||||
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
|
||||
Ablage.raeumeAuf()
|
||||
Ablage.serverAdresse = adresse
|
||||
hatPIN = false
|
||||
gesperrt = false
|
||||
fehlversuche = 0
|
||||
if let adresse {
|
||||
client = CertainClient(basis: adresse)
|
||||
lage = .brauchtAnmeldung
|
||||
@@ -246,6 +268,48 @@ final class AppZustand {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sperren und entsperren
|
||||
|
||||
/// iOS fotografiert die App beim Wegschalten für den App-Umschalter.
|
||||
/// Darum wird sofort gesperrt und nicht erst beim Zurückkommen – sonst
|
||||
/// stünden die Kontostände im Umschalter.
|
||||
func gehtWeg() {
|
||||
guard hatPIN, !gesperrt else { return }
|
||||
seitWannWeg = Date()
|
||||
gesperrt = true
|
||||
}
|
||||
|
||||
/// Kurz weggewischt – etwa für eine eintreffende Nachricht – geht ohne
|
||||
/// Nachfrage wieder auf. Erst nach der Gnadenfrist wird gefragt.
|
||||
func kommtZurueck() {
|
||||
guard let weg = seitWannWeg else { return }
|
||||
seitWannWeg = nil
|
||||
if Date().timeIntervalSince(weg) <= Self.gnadenfrist { gesperrt = false }
|
||||
}
|
||||
|
||||
func entsperrt() {
|
||||
gesperrt = false
|
||||
fehlversuche = 0
|
||||
}
|
||||
|
||||
/// Meldet einen Fehlversuch. Gibt zurück, ob noch Versuche übrig sind;
|
||||
/// beim letzten meldet die App ab.
|
||||
func falschePIN() async -> Bool {
|
||||
fehlversuche += 1
|
||||
guard fehlversuche >= Self.maxFehlversuche else { return true }
|
||||
await meldeAb()
|
||||
return false
|
||||
}
|
||||
|
||||
var verbleibendeVersuche: Int { max(0, Self.maxFehlversuche - fehlversuche) }
|
||||
|
||||
func setzePIN(_ pin: String) {
|
||||
Ablage.setzePIN(pin)
|
||||
hatPIN = true
|
||||
gesperrt = false
|
||||
fehlversuche = 0
|
||||
}
|
||||
|
||||
func aktualisiereEinstellungen() async {
|
||||
guard let client else { return }
|
||||
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
@main
|
||||
struct CertainApp: App {
|
||||
@State private var zustand = AppZustand()
|
||||
@Environment(\.scenePhase) private var phase
|
||||
|
||||
var body: some Scene {
|
||||
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
|
||||
@@ -19,6 +20,19 @@ struct CertainApp: App {
|
||||
.environment(zustand)
|
||||
.task { await zustand.starte() }
|
||||
.preferredColorScheme(schema)
|
||||
// Einmal hier, damit jeder Knopf, Schalter und Link
|
||||
// die Markenfarbe traegt statt des System-Blaus.
|
||||
.tint(.marke)
|
||||
.onChange(of: phase) { _, neue in
|
||||
// `inactive` kommt, bevor iOS das Bild für den
|
||||
// App-Umschalter macht. Dort soll der Sperrbildschirm
|
||||
// stehen, nicht der Kontostand.
|
||||
switch neue {
|
||||
case .inactive, .background: zustand.gehtWeg()
|
||||
case .active: zustand.kommtZurueck()
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,15 @@ struct RootAnsicht: View {
|
||||
ZweitenFaktorEinrichtenHinweis()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,27 @@ extension Color {
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// Die Markenfarbe: dasselbe Grün wie `--accent` in `web/src/styles.css`,
|
||||
/// damit App, Weboberfläche und Appicon eine Familie sind. In Dunkel eine
|
||||
/// Spur heller, sonst säuft das Grün auf dunklem Grund ab.
|
||||
static let marke = Color(uiColor: UIColor { zug in
|
||||
zug.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.373, green: 0.741, blue: 0.569, alpha: 1) // #5fbd91
|
||||
: UIColor(red: 0.247, green: 0.612, blue: 0.447, alpha: 1) // #3f9c72
|
||||
})
|
||||
}
|
||||
|
||||
/// Die Farbauswahl für Kategorien, Schlagwörter und Konten – eine Liste für
|
||||
/// alle drei Formulare, damit sie nicht auseinanderlaufen. Das Markengrün
|
||||
/// steht vorn und ist die Vorbelegung; der Rest bleibt bunt, sonst liessen
|
||||
/// sich die Kategorien in Diagrammen nicht auseinanderhalten.
|
||||
enum Farbwahl {
|
||||
static let standard = "#3f9c72"
|
||||
static let palette = [standard, "#3f51b5", "#b3541e", "#8e24aa",
|
||||
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
|
||||
}
|
||||
|
||||
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
|
||||
///
|
||||
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
|
||||
|
||||
@@ -183,11 +183,6 @@ struct SymbolUndFarbeFeld: View {
|
||||
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
|
||||
]
|
||||
|
||||
private static let farben = [
|
||||
"#3f51b5", "#2e7d55", "#b3541e", "#8e24aa", "#c62828",
|
||||
"#0277bd", "#6b7480", "#5d4037",
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
|
||||
@@ -196,7 +191,7 @@ struct SymbolUndFarbeFeld: View {
|
||||
.foregroundStyle(Color(hex: farbe))
|
||||
}
|
||||
|
||||
auswahl(Self.farben, aktuell: farbe) { farbe = $0 } inhalt: { wert in
|
||||
auswahl(Farbwahl.palette, aktuell: farbe) { farbe = $0 } inhalt: { wert in
|
||||
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ struct BudgetAnsicht: View {
|
||||
} label: {
|
||||
Label(String(localized: "budget.vorlage"), systemImage: "doc.text")
|
||||
}
|
||||
.tint(.indigo)
|
||||
.tint(.marke)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
04F945FE4BAC298FC242354B /* AboListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5FA8AE870EDCB51232CA9E /* AboListe.swift */; };
|
||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4119B85EFF3FB38E84FAA913 /* Konto.swift */; };
|
||||
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */; };
|
||||
18B6A9629AA749B790F21DC9 /* CertainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */; };
|
||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */; };
|
||||
1E53B174991E91B8D25CCE04 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB62E8B3371E688C05C7CDB8 /* Dashboard.swift */; };
|
||||
@@ -17,6 +18,7 @@
|
||||
2685982A4603DE880837D10E /* BudgetAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */; };
|
||||
2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */; };
|
||||
2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2E765F9D8DDC8D46A1BDD6 /* Arbeitsliste.swift */; };
|
||||
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */; };
|
||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */; };
|
||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD0B83BDFDF777917C9382 /* KontenListe.swift */; };
|
||||
402F07ED1E39AB8C3BC6F62B /* AppZustand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */; };
|
||||
@@ -25,6 +27,7 @@
|
||||
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */; };
|
||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */; };
|
||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327787E4869ED7D0C7E9EE63 /* Buchung.swift */; };
|
||||
5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */; };
|
||||
66EE367CB37CDA59EEF0F023 /* EinstellungenAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B6D688EF69B6EC58A8B4BE /* EinstellungenAnsicht.swift */; };
|
||||
6FD2F62BEFE9BE9FE159416C /* antworten.json in Resources */ = {isa = PBXBuildFile; fileRef = 3137E525D1ADE3DB2F8E45A8 /* antworten.json */; };
|
||||
7A5BD17CBEB8E7E61BEC6396 /* GrundlagenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */; };
|
||||
@@ -32,16 +35,20 @@
|
||||
841A4EE94250BDDF88FA2FD4 /* Budget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51DE5BDB909910D586F819F /* Budget.swift */; };
|
||||
89C924D18B1875B037EF08BA /* DashboardAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09BBFDDFA7A1FFC8785925BE /* DashboardAnsicht.swift */; };
|
||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8BE152C9F91584900796ADA /* Grundmodelle.swift */; };
|
||||
90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F14B825905BA99472E4B289 /* BankAnsicht.swift */; };
|
||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A150CAB4967A479B506A997F /* BuchungsListe.swift */; };
|
||||
918BA7A7CC3972ED4D8A93B1 /* Darstellung.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451BA41D9B53FB6B511AF734 /* Darstellung.swift */; };
|
||||
A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = C841BA87849B2A461449BAF5 /* KategorieFormular.swift */; };
|
||||
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; };
|
||||
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */; };
|
||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
|
||||
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = D42C757A9D818A524C4CAC8A /* AppIcon.icon */; };
|
||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
|
||||
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
|
||||
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; };
|
||||
C810555B892E1CAD08053FC3 /* Formular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 778040646D05B1DEEE3BF727 /* Formular.swift */; };
|
||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */; };
|
||||
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */; };
|
||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF1014AC23C916BE571DDC /* Lader.swift */; };
|
||||
DE172F2DBA80FB2E8CE93A06 /* Eingaben.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */; };
|
||||
E49213EDDBF2C0C7995AF558 /* AnmeldeAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */; };
|
||||
@@ -49,6 +56,7 @@
|
||||
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; };
|
||||
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; };
|
||||
F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; };
|
||||
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -69,10 +77,13 @@
|
||||
13C14F1D9CEF39FF3E095239 /* Ablage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ablage.swift; sourceTree = "<group>"; };
|
||||
1AB439591BFEABB503F2E071 /* RootAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootAnsicht.swift; sourceTree = "<group>"; };
|
||||
1E6FE0925FC71A03818FFF72 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1F14B825905BA99472E4B289 /* BankAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BankAnsicht.swift; sourceTree = "<group>"; };
|
||||
23B6CF6893EB57064D36F609 /* GrundlagenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrundlagenTests.swift; sourceTree = "<group>"; };
|
||||
287D314176EBA5E888605F5F /* Geldbetrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geldbetrag.swift; sourceTree = "<group>"; };
|
||||
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nebenraeume.swift; sourceTree = "<group>"; };
|
||||
3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; };
|
||||
327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; };
|
||||
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SperrAnsicht.swift; sourceTree = "<group>"; };
|
||||
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = "<group>"; };
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
|
||||
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -85,9 +96,11 @@
|
||||
647E9AA0BFEDCAFF98BADB9C /* Eingaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eingaben.swift; sourceTree = "<group>"; };
|
||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zeitangaben.swift; sourceTree = "<group>"; };
|
||||
776F26865B849D2318B6819B /* Zustandsansichten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zustandsansichten.swift; sourceTree = "<group>"; };
|
||||
777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorienAnsicht.swift; sourceTree = "<group>"; };
|
||||
778040646D05B1DEEE3BF727 /* Formular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formular.swift; sourceTree = "<group>"; };
|
||||
77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainClient.swift; sourceTree = "<group>"; };
|
||||
78AD0B83BDFDF777917C9382 /* KontenListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontenListe.swift; sourceTree = "<group>"; };
|
||||
89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsAnsicht.swift; sourceTree = "<group>"; };
|
||||
8A1BD38230CAE03DFA02A3F0 /* CertainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertainApp.swift; sourceTree = "<group>"; };
|
||||
8C5FA8AE870EDCB51232CA9E /* AboListe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboListe.swift; sourceTree = "<group>"; };
|
||||
9E12F564D3BD2A67648FBB4A /* BudgetAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetAnsicht.swift; sourceTree = "<group>"; };
|
||||
@@ -100,6 +113,9 @@
|
||||
C24DAC366AE34403646DBE67 /* Zwischenspeicher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zwischenspeicher.swift; sourceTree = "<group>"; };
|
||||
C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIFehler.swift; sourceTree = "<group>"; };
|
||||
C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorieFormular.swift; sourceTree = "<group>"; };
|
||||
D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiterFaktorAnsicht.swift; sourceTree = "<group>"; };
|
||||
D42C757A9D818A524C4CAC8A /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = "<group>"; };
|
||||
DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = "<group>"; };
|
||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; };
|
||||
@@ -175,6 +191,7 @@
|
||||
F8BE152C9F91584900796ADA /* Grundmodelle.swift */,
|
||||
B9AD55C0D1C5CE42053DF335 /* Kategorie.swift */,
|
||||
4119B85EFF3FB38E84FAA913 /* Konto.swift */,
|
||||
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */,
|
||||
75EA409F3F0CEF3DF04C32E7 /* Zeitangaben.swift */,
|
||||
);
|
||||
path = Modelle;
|
||||
@@ -194,6 +211,7 @@
|
||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
|
||||
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
|
||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
|
||||
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */,
|
||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
|
||||
);
|
||||
path = Anmeldung;
|
||||
@@ -202,6 +220,7 @@
|
||||
BC82A6A323BDD2AD8325BEA2 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D42C757A9D818A524C4CAC8A /* AppIcon.icon */,
|
||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
|
||||
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
||||
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
||||
@@ -213,6 +232,7 @@
|
||||
56CB7B1C18136BFD83ACADBD /* Einstellungen */,
|
||||
298E43BBA9248E5A9779DA98 /* Konten */,
|
||||
80EFC9A43394C64E9AEEF5A1 /* Modelle */,
|
||||
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */,
|
||||
DB51F54CC077E85C286AD9DC /* Netzwerk */,
|
||||
524FD85EDE7C4DE5ABE9A8E5 /* Tests */,
|
||||
0EDDD922C06B1465AD13C782 /* Texte */,
|
||||
@@ -279,6 +299,18 @@
|
||||
path = Netzwerk;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F77DCF8FF4A1F732F2FA3EA8 /* Nebenraeume */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1F14B825905BA99472E4B289 /* BankAnsicht.swift */,
|
||||
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */,
|
||||
777281FF90E90AC0B14D2807 /* KategorienAnsicht.swift */,
|
||||
89DDE9775816D9CB03489B56 /* TagsAnsicht.swift */,
|
||||
D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */,
|
||||
);
|
||||
path = Nebenraeume;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAFA069DDABF0E56CAF24C4F /* Arbeitsliste */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -369,6 +401,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */,
|
||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -406,6 +439,7 @@
|
||||
2F3B8F55EB066B0C90200534 /* Arbeitsliste.swift in Sources */,
|
||||
2E0CAE876D1D74771FC1011B /* ArbeitslisteAnsicht.swift in Sources */,
|
||||
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */,
|
||||
90BC49DB37BABF00BE3CC2F7 /* BankAnsicht.swift in Sources */,
|
||||
4EEA2CC5273F9C12D9E0C61D /* Buchung.swift in Sources */,
|
||||
2041749A0179B0AC29AFF2FF /* BuchungFormular.swift in Sources */,
|
||||
917FFB036BF2D36FBD9F777E /* BuchungsListe.swift in Sources */,
|
||||
@@ -424,16 +458,22 @@
|
||||
431D27A182E4DEBD84F0DA05 /* Geldbetrag.swift in Sources */,
|
||||
8EAA9F11CF1FDFE702557B55 /* Grundmodelle.swift in Sources */,
|
||||
D19F572D7B11F3144FDF1CD6 /* Kategorie.swift in Sources */,
|
||||
A16063F94D4F5C1631130609 /* KategorieFormular.swift in Sources */,
|
||||
5415C2CE4B44A7B3611E4D7D /* KategorienAnsicht.swift in Sources */,
|
||||
3F49B22ED7C840858749B143 /* KontenListe.swift in Sources */,
|
||||
0E66BEE1DEE6E4A5D88A6696 /* Konto.swift in Sources */,
|
||||
49AA743A07B0A1E21D2D6AE8 /* KontoFormular.swift in Sources */,
|
||||
D5AFF6E8CE7AB6674BDCEEE1 /* Lader.swift in Sources */,
|
||||
3BE070886D8D49F0BF9B49BC /* Nebenraeume.swift in Sources */,
|
||||
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
|
||||
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
|
||||
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */,
|
||||
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */,
|
||||
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */,
|
||||
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */,
|
||||
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */,
|
||||
1E0AE92B8994298A0468E4EC /* ZweiFaktorAnsicht.swift in Sources */,
|
||||
18384EACDD7DE161B874636C /* ZweiterFaktorAnsicht.swift in Sources */,
|
||||
4C9AC9D9DCA7A94F831AB207 /* Zwischenspeicher.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -471,9 +511,12 @@
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Certain;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -481,8 +524,12 @@
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -651,9 +698,12 @@
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Certain;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -661,8 +711,12 @@
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Stufe 1 zeigt hier nur, woran man ist, und lässt abmelden. Die echten
|
||||
/// Einstellungen (Hauptwährung, Budget-Starttag, Sprache, Gerätemarken)
|
||||
/// kommen in Stufe 5.
|
||||
/// Einstellungen und die Nebenräume: Kategorien, Schlagwörter, Bank,
|
||||
/// zweiter Faktor.
|
||||
///
|
||||
/// Sie hängen hier und nicht als eigene Reiter: Man braucht sie selten, und
|
||||
/// eine Leiste mit zehn Reitern findet niemand mehr.
|
||||
struct EinstellungenAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var zeigeAbmeldefrage = false
|
||||
@State private var markierte: [MarkiertesWort] = []
|
||||
@State private var aenderePIN = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -18,17 +23,83 @@ struct EinstellungenAnsicht: View {
|
||||
LabeledContent(String(localized: "einstellungen.server"),
|
||||
value: adresse.host() ?? adresse.absoluteString)
|
||||
}
|
||||
NavigationLink {
|
||||
ZweiterFaktorAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield")
|
||||
}
|
||||
Button {
|
||||
aenderePIN = true
|
||||
} label: {
|
||||
Label(String(localized: "pin.aendern"), systemImage: "lock.rotation")
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "einstellungen.zugang"))
|
||||
} footer: {
|
||||
Text(String(localized: "einstellungen.markeErklaerung"))
|
||||
}
|
||||
|
||||
Section(String(localized: "einstellungen.ausDemServer")) {
|
||||
LabeledContent(String(localized: "einstellungen.hauptwaehrung"),
|
||||
value: zustand.einstellungen.basiswaehrung)
|
||||
LabeledContent(String(localized: "einstellungen.budgetStarttag"),
|
||||
value: "\(zustand.einstellungen.budgetStarttag)")
|
||||
Section(String(localized: "nebenraeume.titel")) {
|
||||
NavigationLink {
|
||||
KategorienAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "nebenraeume.kategorien"), systemImage: "square.grid.2x2")
|
||||
}
|
||||
NavigationLink {
|
||||
TagsAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "nebenraeume.tags"), systemImage: "tag")
|
||||
}
|
||||
NavigationLink {
|
||||
BankAnsicht()
|
||||
} label: {
|
||||
Label(String(localized: "nebenraeume.bank"), systemImage: "building.columns")
|
||||
}
|
||||
}
|
||||
|
||||
if !markierte.isEmpty {
|
||||
Section {
|
||||
ForEach(markierte) { wort in
|
||||
MarkiertesWortZeile(wort: wort,
|
||||
behalten: { Task { await behalte(wort) } },
|
||||
entfernen: { Task { await entferne(wort) } })
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "kategorien.markiertTitel"))
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.markiertErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker(String(localized: "einstellungen.erscheinungsbild"),
|
||||
selection: bindung(\.erscheinungsbild, "theme")) {
|
||||
Text(String(localized: "einstellungen.system")).tag("system")
|
||||
Text(String(localized: "einstellungen.hell")).tag("light")
|
||||
Text(String(localized: "einstellungen.dunkel")).tag("dark")
|
||||
}
|
||||
Picker(String(localized: "einstellungen.hauptwaehrung"),
|
||||
selection: bindung(\.basiswaehrung, "base_currency")) {
|
||||
ForEach(zustand.einstellungen.aktiveWaehrungen, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
Picker(String(localized: "einstellungen.budgetStarttag"),
|
||||
selection: tagBindung) {
|
||||
ForEach(1...28, id: \.self) { tag in
|
||||
Text("\(tag).").tag(tag)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "einstellungen.ausDemServer"))
|
||||
} footer: {
|
||||
// Die Hauptwährung rechnet alle Auswertungen um. Das gilt
|
||||
// für jedes Gerät, nicht nur für dieses.
|
||||
Text(String(localized: "einstellungen.serverweitErklaerung"))
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -39,8 +110,12 @@ struct EinstellungenAnsicht: View {
|
||||
Text(String(localized: "einstellungen.abmeldenErklaerung"))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $aenderePIN) {
|
||||
PINEinrichtenAnsicht(pflicht: false)
|
||||
}
|
||||
.navigationTitle(String(localized: "reiter.einstellungen"))
|
||||
.refreshable { await zustand.aktualisiereEinstellungen() }
|
||||
.refreshable { await lade() }
|
||||
.task { await lade() }
|
||||
.confirmationDialog(String(localized: "einstellungen.abmeldenFrage"),
|
||||
isPresented: $zeigeAbmeldefrage, titleVisibility: .visible) {
|
||||
Button(String(localized: "allgemein.abmelden"), role: .destructive) {
|
||||
@@ -49,4 +124,87 @@ struct EinstellungenAnsicht: View {
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Einstellungen schreiben
|
||||
|
||||
/// Eine Bindung, die beim Setzen gleich zum Server geht.
|
||||
///
|
||||
/// Ein „Sichern"-Knopf wäre hier falsch: Es ist je Zeile genau ein Wert,
|
||||
/// und das Backend nimmt ihn einzeln entgegen (`PUT /settings/{key}`).
|
||||
private func bindung(_ pfad: KeyPath<Einstellungen, String>,
|
||||
_ schluessel: String) -> Binding<String> {
|
||||
Binding(get: { zustand.einstellungen[keyPath: pfad] },
|
||||
set: { neu in Task { await setze(schluessel, neu) } })
|
||||
}
|
||||
|
||||
private var tagBindung: Binding<Int> {
|
||||
Binding(get: { zustand.einstellungen.budgetStarttag },
|
||||
set: { neu in Task { await setze("budget_start_day", String(neu)) } })
|
||||
}
|
||||
|
||||
private func setze(_ schluessel: String, _ wert: String) async {
|
||||
guard let client = zustand.client else { return }
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
_ = try await client.setzeEinstellung(schluessel, wert)
|
||||
}
|
||||
if geglueckt { await zustand.aktualisiereEinstellungen() }
|
||||
}
|
||||
|
||||
// MARK: - Markierte Erkennungswörter
|
||||
|
||||
private func lade() async {
|
||||
await zustand.aktualisiereEinstellungen()
|
||||
guard let client = zustand.client else { return }
|
||||
markierte = (try? await client.markierteErkennungswoerter()) ?? []
|
||||
}
|
||||
|
||||
private func behalte(_ wort: MarkiertesWort) async {
|
||||
guard let client = zustand.client else { return }
|
||||
if await vorgang.fuehreAus({ try await client.bestaetigeErkennungswort(wort.id) }) {
|
||||
await lade()
|
||||
}
|
||||
}
|
||||
|
||||
private func entferne(_ wort: MarkiertesWort) async {
|
||||
guard let client = zustand.client else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) {
|
||||
await lade()
|
||||
await zustand.ladeNachschlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Erkennungswort, das oft wieder korrigiert wird.
|
||||
private struct MarkiertesWortZeile: View {
|
||||
let wort: MarkiertesWort
|
||||
let behalten: () -> Void
|
||||
let entfernen: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(wort.word)
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
Text(wort.categoryName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(String(format: String(localized: "kategorien.markiertZaehler"),
|
||||
wort.appliedCount, wort.overriddenCount))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(String(localized: "kategorien.behalten"), action: behalten)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
Button(String(localized: "kategorien.entfernen"), role: .destructive,
|
||||
action: entfernen)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,218 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Schlagwörter
|
||||
|
||||
/// `KeywordOut` – ein Wort, an dem die Erkennung eine Kategorie festmacht.
|
||||
///
|
||||
/// Nicht zu verwechseln mit `Schlagwort` (einem Tag): Ein Tag klebt an einer
|
||||
/// Buchung, ein Schlagwort hängt an einer **Kategorie** und wirkt nur auf
|
||||
/// künftige Importe.
|
||||
struct Erkennungswort: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let categoryId: Int
|
||||
let word: String
|
||||
/// Nur wenn der Betrag mitentscheiden soll – „Migros" über 200 ist
|
||||
/// Haushalt, darunter Lebensmittel.
|
||||
let amountCents: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, word
|
||||
case categoryId = "category_id"
|
||||
case amountCents = "amount_cents"
|
||||
}
|
||||
}
|
||||
|
||||
/// `FlaggedKeywordOut` – ein Wort, das oft wieder korrigiert wird.
|
||||
///
|
||||
/// Ab fünf Anwendungen und mindestens einem Drittel Korrekturen meldet das
|
||||
/// Backend es zur Prüfung. Behalten oder entfernen entscheidet der Mensch.
|
||||
struct MarkiertesWort: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let categoryId: Int?
|
||||
let categoryName: String
|
||||
let word: String
|
||||
let appliedCount: Int
|
||||
let overriddenCount: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, word
|
||||
case categoryId = "category_id"
|
||||
case categoryName = "category_name"
|
||||
case appliedCount = "applied_count"
|
||||
case overriddenCount = "overridden_count"
|
||||
}
|
||||
}
|
||||
|
||||
/// `KeywordCreate`
|
||||
struct ErkennungswortAnlegen: Encodable, Sendable {
|
||||
let word: String
|
||||
var amountCents: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case word
|
||||
case amountCents = "amount_cents"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Kategorien schreiben
|
||||
|
||||
/// `CategoryCreate`
|
||||
struct KategorieAnlegen: Encodable, Sendable {
|
||||
let groupId: Int
|
||||
let name: String
|
||||
var icon = "more-horizontal"
|
||||
var color = "#6b7480"
|
||||
/// Zählt in keiner Auswertung mit – etwa durchlaufende Posten.
|
||||
var excludedFromStats = false
|
||||
/// Eine Ausgabe, die kein Verlust ist: Sparen.
|
||||
var spendingIsGood = false
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, icon, color
|
||||
case groupId = "group_id"
|
||||
case excludedFromStats = "excluded_from_stats"
|
||||
case spendingIsGood = "spending_is_good"
|
||||
}
|
||||
}
|
||||
|
||||
/// `CategoryUpdate` – nur die gesetzten Felder gehen mit.
|
||||
struct KategorieAendern: Encodable, Sendable {
|
||||
var name: String?
|
||||
var groupId: Int?
|
||||
var icon: String?
|
||||
var color: String?
|
||||
var isArchived: Bool?
|
||||
var excludedFromStats: Bool?
|
||||
var spendingIsGood: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, icon, color
|
||||
case groupId = "group_id"
|
||||
case isArchived = "is_archived"
|
||||
case excludedFromStats = "excluded_from_stats"
|
||||
case spendingIsGood = "spending_is_good"
|
||||
}
|
||||
}
|
||||
|
||||
/// `CategoryGroupCreate`
|
||||
struct GruppeAnlegen: Encodable, Sendable {
|
||||
let name: String
|
||||
var kind: Kategorieart = .expense
|
||||
var icon = "list"
|
||||
var color = "#6b7480"
|
||||
}
|
||||
|
||||
/// `CategoryGroupUpdate`
|
||||
struct GruppeAendern: Encodable, Sendable {
|
||||
var name: String?
|
||||
var icon: String?
|
||||
var color: String?
|
||||
}
|
||||
|
||||
// MARK: - Tags schreiben
|
||||
|
||||
/// `TagCreate`
|
||||
struct TagAnlegen: Encodable, Sendable {
|
||||
let name: String
|
||||
var color = "#6b7480"
|
||||
}
|
||||
|
||||
/// `TagUpdate`
|
||||
struct TagAendern: Encodable, Sendable {
|
||||
var name: String?
|
||||
var color: String?
|
||||
}
|
||||
|
||||
// MARK: - Zweiter Faktor
|
||||
|
||||
/// `TwoFactorStatusOut`
|
||||
struct ZweiterFaktorStand: Codable, Sendable, Hashable {
|
||||
let enabled: Bool
|
||||
let recoveryCodesLeft: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case enabled
|
||||
case recoveryCodesLeft = "recovery_codes_left"
|
||||
}
|
||||
}
|
||||
|
||||
/// `TwoFactorSetupOut` – das noch **nicht** aktive Geheimnis.
|
||||
///
|
||||
/// Gültig wird es erst, wenn ein Code daraus geprüft wurde. Wer die
|
||||
/// Einrichtung abbricht, lässt einfach ein unbenutztes Geheimnis stehen.
|
||||
struct ZweiterFaktorEinrichtung: Codable, Sendable, Hashable {
|
||||
let secret: String
|
||||
let otpauthUri: String
|
||||
/// Der QR-Code als `data:`-URI. Auf dem Telefon selten gebraucht – dort
|
||||
/// führt der Weg über `otpauthUri` direkt in die Authenticator-App.
|
||||
let qrPng: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case secret
|
||||
case otpauthUri = "otpauth_uri"
|
||||
case qrPng = "qr_png"
|
||||
}
|
||||
}
|
||||
|
||||
/// `RecoveryCodesOut` – einmal zu sehen, danach nie wieder.
|
||||
struct Rettungscodes: Codable, Sendable, Hashable {
|
||||
let codes: [String]
|
||||
}
|
||||
|
||||
struct ZweiterFaktorCode: Encodable, Sendable {
|
||||
let code: String
|
||||
}
|
||||
|
||||
// MARK: - Bank
|
||||
|
||||
/// `BankConnectionOut` – eine bestehende Anbindung.
|
||||
///
|
||||
/// Hergestellt wird sie am Rechner: Die Bank leitet fest auf
|
||||
/// `{base_url}/bank/rueckkehr` zurück. Abrufen geht von hier.
|
||||
struct Bankverbindung: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let aspspName: String
|
||||
let aspspCountry: String
|
||||
let validUntil: Zeitpunkt?
|
||||
let lastSyncAt: Zeitpunkt?
|
||||
/// Takt des automatischen Abrufs in Minuten, 0 = aus.
|
||||
let pollMinutes: Int
|
||||
let lastError: String?
|
||||
let expiresSoon: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case aspspName = "aspsp_name"
|
||||
case aspspCountry = "aspsp_country"
|
||||
case validUntil = "valid_until"
|
||||
case lastSyncAt = "last_sync_at"
|
||||
case pollMinutes = "poll_minutes"
|
||||
case lastError = "last_error"
|
||||
case expiresSoon = "expires_soon"
|
||||
}
|
||||
}
|
||||
|
||||
/// Was ein Abruf zurückmeldet.
|
||||
struct Abrufergebnis: Codable, Sendable, Hashable {
|
||||
let geholt: Int
|
||||
let neu: Int
|
||||
let gebucht: Int?
|
||||
let vorgemerkt: Int?
|
||||
let doppelt: Int?
|
||||
let uebertraege: Int?
|
||||
/// Die Verbindung hat kein zugeordnetes Konto – dann wurde nichts geholt.
|
||||
let ohneZuordnung: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case geholt, neu, gebucht, vorgemerkt, doppelt, uebertraege
|
||||
case ohneZuordnung = "ohne_zuordnung"
|
||||
}
|
||||
|
||||
/// Ein Satz für die Rückmeldung.
|
||||
var meldung: String {
|
||||
if ohneZuordnung == true {
|
||||
return String(localized: "bank.ohneZuordnung")
|
||||
}
|
||||
return String(format: String(localized: "bank.abrufErgebnis"), geholt, neu)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Bankanbindungen – abrufen, Salden auffrischen.
|
||||
///
|
||||
/// **Herstellen geht hier nicht.** Die Bank leitet fest auf
|
||||
/// `{base_url}/bank/rueckkehr` zurück, registriert bei Enable Banking; dieser
|
||||
/// Weg führt zwangsläufig über den Browser am Rechner. Abrufen dagegen ist
|
||||
/// genau das, was man unterwegs will.
|
||||
struct BankAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<[Bankverbindung]>?
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var meldung: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { liste in
|
||||
inhalt(liste)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "nebenraeume.bank"))
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
let neuer = Lader<[Bankverbindung]>(schluessel: "bank/connections") {
|
||||
try await client.bankverbindungen()
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
.alert(String(localized: "bank.abrufFertig"),
|
||||
isPresented: .constant(meldung != nil)) {
|
||||
Button(String(localized: "allgemein.ok")) { meldung = nil }
|
||||
} message: {
|
||||
Text(meldung ?? "")
|
||||
}
|
||||
.alert(String(localized: "fehler.titel"),
|
||||
isPresented: .constant(vorgang.fehler != nil)) {
|
||||
Button(String(localized: "allgemein.ok")) { vorgang.fehler = nil }
|
||||
} message: {
|
||||
Text(vorgang.fehler ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inhalt(_ liste: [Bankverbindung]) -> some View {
|
||||
if liste.isEmpty {
|
||||
LeerAnsicht(titel: String(localized: "bank.leerTitel"),
|
||||
erklaerung: String(localized: "bank.leerErklaerung"),
|
||||
symbol: "building.columns")
|
||||
} else {
|
||||
List {
|
||||
ForEach(liste) { verbindung in
|
||||
Section {
|
||||
VerbindungsZeile(verbindung: verbindung)
|
||||
|
||||
Button {
|
||||
Task { await rufeAb(verbindung) }
|
||||
} label: {
|
||||
Label(String(localized: "bank.abrufen"),
|
||||
systemImage: "arrow.down.circle")
|
||||
}
|
||||
Button {
|
||||
Task { await frischeSalden(verbindung) }
|
||||
} label: {
|
||||
Label(String(localized: "bank.saldenAuffrischen"),
|
||||
systemImage: "arrow.clockwise")
|
||||
}
|
||||
} header: {
|
||||
Text(verbindung.aspspName)
|
||||
} footer: {
|
||||
if let fehler = verbindung.lastError {
|
||||
// Der letzte Fehler steht am Server. Ihn zu
|
||||
// verschweigen hiesse, den Nutzer vergeblich
|
||||
// abrufen zu lassen.
|
||||
Label(fehler, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await lader?.aktualisiere() }
|
||||
.disabled(vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
|
||||
private func rufeAb(_ verbindung: Bankverbindung) async {
|
||||
guard let client = zustand.client else { return }
|
||||
var ergebnis: Abrufergebnis?
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
ergebnis = try await client.rufeBankAb(verbindung.id)
|
||||
}
|
||||
if geglueckt {
|
||||
meldung = ergebnis?.meldung
|
||||
await lader?.aktualisiere()
|
||||
}
|
||||
}
|
||||
|
||||
private func frischeSalden(_ verbindung: Bankverbindung) async {
|
||||
guard let client = zustand.client else { return }
|
||||
if await vorgang.fuehreAus({ try await client.frischeSaldenAuf(verbindung.id) }) {
|
||||
meldung = String(localized: "bank.saldenAufgefrischt")
|
||||
await lader?.aktualisiere()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct VerbindungsZeile: View {
|
||||
let verbindung: Bankverbindung
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
LabeledContent(String(localized: "bank.land"), value: verbindung.aspspCountry)
|
||||
|
||||
if let zuletzt = verbindung.lastSyncAt {
|
||||
LabeledContent(String(localized: "bank.zuletzt")) {
|
||||
Text(zuletzt.datum.formatted(.relative(presentation: .named)))
|
||||
}
|
||||
} else {
|
||||
LabeledContent(String(localized: "bank.zuletzt"),
|
||||
value: String(localized: "bank.nie"))
|
||||
}
|
||||
|
||||
if let gueltig = verbindung.validUntil {
|
||||
LabeledContent(String(localized: "bank.gueltigBis")) {
|
||||
Text(gueltig.datum.formatted(.dateTime.day().month().year()))
|
||||
// Eine auslaufende Anbindung bricht sonst ohne
|
||||
// Vorwarnung ab, und niemand weiss warum.
|
||||
.foregroundStyle(verbindung.expiresSoon ? .orange : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if verbindung.pollMinutes > 0 {
|
||||
LabeledContent(String(localized: "bank.takt")) {
|
||||
Text(String(format: String(localized: "bank.alleNMinuten"),
|
||||
verbindung.pollMinutes))
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Eine Kategorie anlegen oder ändern.
|
||||
struct KategorieFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
/// Neu in dieser Gruppe. Die Art erbt sie von der Gruppe – eine
|
||||
/// Einnahme-Kategorie in einer Ausgabe-Gruppe gibt es nicht.
|
||||
case neu(Int, Kategorieart)
|
||||
case aendern(Kategorie)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu(let gruppe, _): return -gruppe
|
||||
case .aendern(let kategorie): return kategorie.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let zweck: Zweck
|
||||
var fertig: () -> Void
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
|
||||
@State private var name = ""
|
||||
@State private var symbol = "more-horizontal"
|
||||
@State private var farbe = Farbwahl.standard
|
||||
@State private var gruppeId = 0
|
||||
@State private var istSparen = false
|
||||
@State private var ohneAuswertung = false
|
||||
@State private var istArchiviert = false
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "konto.name"), text: $name)
|
||||
Picker(String(localized: "kategorien.gruppe"), selection: $gruppeId) {
|
||||
ForEach(passendeGruppen) { gruppe in
|
||||
Text(gruppe.name).tag(gruppe.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||
}
|
||||
|
||||
if art == .expense {
|
||||
Section {
|
||||
Toggle(String(localized: "kategorien.istSparen"), isOn: $istSparen)
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.istSparenErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "kategorien.ohneAuswertungSchalter"),
|
||||
isOn: $ohneAuswertung)
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.ohneAuswertungErklaerung"))
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Toggle(String(localized: "konto.archiviert"), isOn: $istArchiviert)
|
||||
Button(String(localized: "kategorien.loeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.archivierenStattLoeschen"))
|
||||
}
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(titel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(vorgang.laeuft)
|
||||
.confirmationDialog(String(localized: "kategorien.loeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "kategorien.loeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "kategorien.loeschenErklaerung"))
|
||||
}
|
||||
.onAppear(perform: belegeVor)
|
||||
}
|
||||
}
|
||||
|
||||
private var titel: String {
|
||||
switch zweck {
|
||||
case .neu: return String(localized: "kategorien.neu")
|
||||
case .aendern: return String(localized: "kategorien.aendern")
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Art diese Kategorie hat – sie steckt in der Gruppe, nicht in
|
||||
/// der Kategorie selbst.
|
||||
private var art: Kategorieart {
|
||||
switch zweck {
|
||||
case .neu(_, let kind): return kind
|
||||
case .aendern(let kategorie): return kategorie.type
|
||||
}
|
||||
}
|
||||
|
||||
/// Umhängen nur innerhalb derselben Art: Eine Ausgabe-Kategorie in einer
|
||||
/// Einnahme-Gruppe wäre ein Widerspruch.
|
||||
private var passendeGruppen: [Kategoriegruppe] {
|
||||
zustand.nachschlag.gruppen.filter { $0.kind == art }
|
||||
}
|
||||
|
||||
private func belegeVor() {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
switch zweck {
|
||||
case .neu(let gruppe, _):
|
||||
gruppeId = gruppe
|
||||
case .aendern(let kategorie):
|
||||
name = kategorie.name
|
||||
symbol = kategorie.icon
|
||||
farbe = kategorie.color
|
||||
gruppeId = kategorie.groupId
|
||||
istSparen = kategorie.spendingIsGood
|
||||
ohneAuswertung = kategorie.excludedFromStats
|
||||
istArchiviert = kategorie.isArchived
|
||||
}
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// Sparen gibt es nur bei Ausgaben. Beim Wechsel der Art bliebe der
|
||||
// Haken sonst gesetzt und wirkte still weiter.
|
||||
let sparen = art == .expense && istSparen
|
||||
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeKategorieAn(KategorieAnlegen(
|
||||
groupId: gruppeId, name: sauber, icon: symbol, color: farbe,
|
||||
excludedFromStats: ohneAuswertung, spendingIsGood: sparen))
|
||||
case .aendern(let kategorie):
|
||||
_ = try await client.aendereKategorie(kategorie.id, KategorieAendern(
|
||||
name: sauber, groupId: gruppeId, icon: symbol, color: farbe,
|
||||
isArchived: istArchiviert, excludedFromStats: ohneAuswertung,
|
||||
spendingIsGood: sparen))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let kategorie) = zweck else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheKategorie(kategorie.id) }) {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Kategoriegruppe anlegen oder ändern.
|
||||
struct GruppenFormular: View {
|
||||
enum Zweck: Equatable, Identifiable {
|
||||
case neu
|
||||
case aendern(Kategoriegruppe)
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .neu: return 0
|
||||
case .aendern(let gruppe): return gruppe.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let zweck: Zweck
|
||||
var fertig: () -> Void
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
|
||||
@State private var name = ""
|
||||
@State private var art: Kategorieart = .expense
|
||||
@State private var symbol = "list"
|
||||
@State private var farbe = Farbwahl.standard
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "konto.name"), text: $name)
|
||||
if case .neu = zweck {
|
||||
Picker(String(localized: "kategorien.art"), selection: $art) {
|
||||
Text(String(localized: "kategorien.ausgaben")).tag(Kategorieart.expense)
|
||||
Text(String(localized: "kategorien.einnahmen")).tag(Kategorieart.income)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
} else {
|
||||
// Die Art einer Gruppe nachträglich zu wechseln würde
|
||||
// alle ihre Kategorien die Seite wechseln lassen.
|
||||
LabeledContent(String(localized: "kategorien.art"),
|
||||
value: art == .income
|
||||
? String(localized: "kategorien.einnahmen")
|
||||
: String(localized: "kategorien.ausgaben"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
SymbolUndFarbeFeld(symbol: $symbol, farbe: $farbe)
|
||||
}
|
||||
|
||||
if case .aendern = zweck {
|
||||
Section {
|
||||
Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) {
|
||||
fragtNachLoeschen = true
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.gruppeLoeschenErklaerung"))
|
||||
}
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "kategorien.gruppe"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(String(localized: "kategorien.gruppeLoeschenFrage"),
|
||||
isPresented: $fragtNachLoeschen, titleVisibility: .visible) {
|
||||
Button(String(localized: "kategorien.gruppeLoeschen"), role: .destructive) {
|
||||
Task { await loesche() }
|
||||
}
|
||||
Button(String(localized: "allgemein.abbrechen"), role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
if case .aendern(let gruppe) = zweck {
|
||||
name = gruppe.name
|
||||
art = gruppe.kind
|
||||
symbol = gruppe.icon
|
||||
farbe = gruppe.color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
switch zweck {
|
||||
case .neu:
|
||||
_ = try await client.legeGruppeAn(GruppeAnlegen(
|
||||
name: sauber, kind: art, icon: symbol, color: farbe))
|
||||
case .aendern(let gruppe):
|
||||
_ = try await client.aendereGruppe(gruppe.id, GruppeAendern(
|
||||
name: sauber, icon: symbol, color: farbe))
|
||||
}
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche() async {
|
||||
guard let client = zustand.client, case .aendern(let gruppe) = zweck else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheGruppe(gruppe.id) }) {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Kategorien, nach Gruppen. Antippen öffnet die Kategorie samt ihren
|
||||
/// Erkennungswörtern.
|
||||
struct KategorienAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var bearbeitet: KategorieFormular.Zweck?
|
||||
@State private var gruppeBearbeiten: GruppenFormular.Zweck?
|
||||
@State private var zeigeArchivierte = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if zustand.nachschlag.gruppen.isEmpty {
|
||||
Section {
|
||||
Text(String(localized: "kategorien.leer"))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(zustand.nachschlag.gruppen) { gruppe in
|
||||
Section {
|
||||
ForEach(sichtbar(gruppe)) { kategorie in
|
||||
NavigationLink {
|
||||
KategorieAnsicht(kategorie: kategorie)
|
||||
} label: {
|
||||
KategorieZeile(kategorie: kategorie)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
bearbeitet = .aendern(kategorie)
|
||||
} label: {
|
||||
Label(String(localized: "allgemein.bearbeiten"),
|
||||
systemImage: "pencil")
|
||||
}
|
||||
.tint(.marke)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
bearbeitet = .neu(gruppe.id, gruppe.kind)
|
||||
} label: {
|
||||
Label(String(localized: "kategorien.neu"), systemImage: "plus")
|
||||
.font(.callout)
|
||||
}
|
||||
} header: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: Symbole.sf(gruppe.icon))
|
||||
.foregroundStyle(Color(hex: gruppe.color))
|
||||
Text(gruppe.name)
|
||||
Text(gruppe.kind == .income
|
||||
? String(localized: "kategorien.einnahmen")
|
||||
: String(localized: "kategorien.ausgaben"))
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
Button {
|
||||
gruppeBearbeiten = .aendern(gruppe)
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
}
|
||||
.textCase(nil)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(String(localized: "nebenraeume.kategorien"))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Toggle(String(localized: "kategorien.archivierteZeigen"),
|
||||
isOn: $zeigeArchivierte)
|
||||
Button {
|
||||
gruppeBearbeiten = .neu
|
||||
} label: {
|
||||
Label(String(localized: "kategorien.neueGruppe"),
|
||||
systemImage: "folder.badge.plus")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable { await zustand.ladeNachschlag() }
|
||||
.sheet(item: $bearbeitet) { zweck in
|
||||
KategorieFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
.sheet(item: $gruppeBearbeiten) { zweck in
|
||||
GruppenFormular(zweck: zweck) { Task { await zustand.ladeNachschlag() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
}
|
||||
|
||||
private func sichtbar(_ gruppe: Kategoriegruppe) -> [Kategorie] {
|
||||
gruppe.categories.filter { zeigeArchivierte || !$0.isArchived }
|
||||
}
|
||||
}
|
||||
|
||||
private struct KategorieZeile: View {
|
||||
let kategorie: Kategorie
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Symbolplakette(symbol: kategorie.icon, farbe: kategorie.color, groesse: 30)
|
||||
Text(kategorie.name)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 6)
|
||||
ForEach(merkmale, id: \.self) { merkmal in
|
||||
Text(merkmal)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
if kategorie.keywordCount > 0 {
|
||||
Label("\(kategorie.keywordCount)", systemImage: "text.magnifyingglass")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nur was vom Normalfall abweicht. Eine Plakette an jeder Zeile sagt
|
||||
/// nichts mehr.
|
||||
private var merkmale: [String] {
|
||||
var liste: [String] = []
|
||||
if kategorie.isArchived { liste.append(String(localized: "konten.archiviert")) }
|
||||
if kategorie.spendingIsGood { liste.append(String(localized: "kategorien.sparen")) }
|
||||
if kategorie.excludedFromStats {
|
||||
liste.append(String(localized: "kategorien.ohneAuswertung"))
|
||||
}
|
||||
return liste
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Eine Kategorie mit ihren Erkennungswörtern
|
||||
|
||||
/// Was die Erkennung an dieser Kategorie festmacht.
|
||||
///
|
||||
/// Erkennungswörter wirken **nur auf künftige Importe** – an bestehenden
|
||||
/// Buchungen ändert sich nichts. Das steht auch so unter der Liste, sonst
|
||||
/// wartet jemand vergeblich darauf, dass sich etwas rührt.
|
||||
struct KategorieAnsicht: View {
|
||||
let kategorie: Kategorie
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<[Erkennungswort]>?
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var neuesWort = ""
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { woerter in
|
||||
liste(woerter)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(kategorie.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
let neuer = Lader<[Erkennungswort]>(schluessel: "keywords/\(kategorie.id)") {
|
||||
try await client.erkennungswoerter(kategorie: kategorie.id)
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func liste(_ woerter: [Erkennungswort]) -> some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(woerter) { wort in
|
||||
HStack {
|
||||
Text(wort.word)
|
||||
Spacer()
|
||||
if let cents = wort.amountCents {
|
||||
Text(Geldbetrag(cents: cents,
|
||||
waehrung: zustand.einstellungen.basiswaehrung)
|
||||
.formatiert())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
Task { await loesche(wort) }
|
||||
} label: {
|
||||
Label(String(localized: "allgemein.loeschen"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField(String(localized: "kategorien.wortHinzufuegen"), text: $neuesWort)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.onSubmit { Task { await fuegeHinzu() } }
|
||||
Button {
|
||||
Task { await fuegeHinzu() }
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
.disabled(neuesWort.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| vorgang.laeuft)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "kategorien.erkennungswoerter"))
|
||||
} footer: {
|
||||
Text(String(localized: "kategorien.erkennungErklaerung"))
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { await lader?.aktualisiere() }
|
||||
}
|
||||
|
||||
private func fuegeHinzu() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let wort = neuesWort.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !wort.isEmpty else { return }
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
_ = try await client.legeErkennungswortAn(
|
||||
kategorie: kategorie.id, ErkennungswortAnlegen(word: wort))
|
||||
}
|
||||
if geglueckt {
|
||||
neuesWort = ""
|
||||
await lader?.aktualisiere()
|
||||
// Die Kategorie führt die Anzahl mit; ohne das bleibt die
|
||||
// Plakette in der Übersicht auf dem alten Stand stehen.
|
||||
await zustand.ladeNachschlag()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche(_ wort: Erkennungswort) async {
|
||||
guard let client = zustand.client else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheErkennungswort(wort.id) }) {
|
||||
await lader?.aktualisiere()
|
||||
await zustand.ladeNachschlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Schlagwörter: frei vergebene Etiketten, quer zur Kategorie.
|
||||
///
|
||||
/// Die Kategorie beantwortet „wofür war das Geld". Alles, was quer dazu
|
||||
/// liegt – „Urlaub Island", „steuerlich absetzbar" –, passt in keine, weil
|
||||
/// eine Buchung davon mehrere auf einmal sein kann.
|
||||
struct TagsAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var bearbeitet: Schlagwort?
|
||||
@State private var neuerName = ""
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(zustand.nachschlag.schlagwoerter) { etikett in
|
||||
Button {
|
||||
bearbeitet = etikett
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(Color(hex: etikett.color))
|
||||
.frame(width: 14, height: 14)
|
||||
Text(etikett.name)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
Task { await loesche(etikett) }
|
||||
} label: {
|
||||
Label(String(localized: "allgemein.loeschen"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField(String(localized: "tags.neu"), text: $neuerName)
|
||||
.onSubmit { Task { await legeAn() } }
|
||||
Button {
|
||||
Task { await legeAn() }
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
.disabled(neuerName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| vorgang.laeuft)
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "tags.erklaerung"))
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(String(localized: "nebenraeume.tags"))
|
||||
.refreshable { await zustand.ladeNachschlag() }
|
||||
.sheet(item: $bearbeitet) { etikett in
|
||||
TagFormular(etikett: etikett) { Task { await zustand.ladeNachschlag() } }
|
||||
.environment(zustand)
|
||||
}
|
||||
}
|
||||
|
||||
private func legeAn() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let name = neuerName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return }
|
||||
// Reihum eine Farbe, damit nicht alle grau sind. Ändern geht danach.
|
||||
let farbe = Farbwahl.palette[zustand.nachschlag.schlagwoerter.count % Farbwahl.palette.count]
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
_ = try await client.legeTagAn(TagAnlegen(name: name, color: farbe))
|
||||
}
|
||||
if geglueckt {
|
||||
neuerName = ""
|
||||
await zustand.ladeNachschlag()
|
||||
}
|
||||
}
|
||||
|
||||
private func loesche(_ etikett: Schlagwort) async {
|
||||
guard let client = zustand.client else { return }
|
||||
if await vorgang.fuehreAus({ try await client.loescheTag(etikett.id) }) {
|
||||
await zustand.ladeNachschlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Schlagwort umbenennen oder umfärben.
|
||||
struct TagFormular: View {
|
||||
let etikett: Schlagwort
|
||||
var fertig: () -> Void
|
||||
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var name = ""
|
||||
@State private var farbe = Farbwahl.standard
|
||||
@State private var vorbelegt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: "konto.name"), text: $name)
|
||||
}
|
||||
Section {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Farbwahl.palette, id: \.self) { wert in
|
||||
Button { farbe = wert } label: {
|
||||
Circle()
|
||||
.fill(Color(hex: wert))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay {
|
||||
if wert == farbe {
|
||||
Circle().strokeBorder(.primary, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "nebenraeume.tags"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.sichern")) { Task { await sichere() } }
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
guard !vorbelegt else { return }
|
||||
vorbelegt = true
|
||||
name = etikett.name
|
||||
farbe = etikett.color
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sichere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
let sauber = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
_ = try await client.aendereTag(etikett.id,
|
||||
TagAendern(name: sauber, color: farbe))
|
||||
}
|
||||
if geglueckt {
|
||||
fertig()
|
||||
schliesse()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Den zweiten Faktor einrichten.
|
||||
///
|
||||
/// Bis Stufe 5 verwies die App dafür in den Browser. Drei Schritte:
|
||||
/// Geheimnis anlegen, in die Authenticator-App übernehmen, mit einem Code
|
||||
/// scharfschalten.
|
||||
///
|
||||
/// **Abschalten geht hier nicht**, und das ist Absicht: Es verlangt das
|
||||
/// Kontopasswort, und die App speichert keines. Wer es abschalten will,
|
||||
/// macht das am Rechner – ein Vollzugang auf diesem Gerät soll den Schutz
|
||||
/// des Kontos nicht aufheben können.
|
||||
struct ZweiterFaktorAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var lader: Lader<ZweiterFaktorStand>?
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var einrichtung: ZweiterFaktorEinrichtung?
|
||||
@State private var code = ""
|
||||
@State private var rettungscodes: [String]?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let lader {
|
||||
Inhalt(zustand: lader.zustand, nochmal: { Task { await lader.lade() } }) { stand in
|
||||
inhalt(stand)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "zweifaktor.titel"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
guard lader == nil, let client = zustand.client else { return }
|
||||
let neuer = Lader<ZweiterFaktorStand>(schluessel: "auth/2fa/status") {
|
||||
try await client.zweiterFaktorStand()
|
||||
}
|
||||
lader = neuer
|
||||
await neuer.lade()
|
||||
}
|
||||
.sheet(item: Binding(get: { rettungscodes.map(Codeliste.init) },
|
||||
set: { if $0 == nil { rettungscodes = nil } })) { liste in
|
||||
RettungscodeBlatt(codes: liste.codes) {
|
||||
rettungscodes = nil
|
||||
Task { await lader?.aktualisiere() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nur da, um die Codes an `.sheet(item:)` zu geben.
|
||||
private struct Codeliste: Identifiable {
|
||||
let codes: [String]
|
||||
var id: String { codes.joined() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inhalt(_ stand: ZweiterFaktorStand) -> some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent(String(localized: "zweifaktor.stand")) {
|
||||
Text(stand.enabled ? String(localized: "zweifaktor.aktiv")
|
||||
: String(localized: "zweifaktor.inaktiv"))
|
||||
.foregroundStyle(stand.enabled ? .green : .secondary)
|
||||
}
|
||||
if stand.enabled {
|
||||
LabeledContent(String(localized: "zweifaktor.rettungscodes"),
|
||||
value: "\(stand.recoveryCodesLeft)")
|
||||
}
|
||||
} footer: {
|
||||
Text(stand.enabled ? String(localized: "zweifaktor.aktivErklaerung")
|
||||
: String(localized: "zweifaktor.inaktivErklaerung"))
|
||||
}
|
||||
|
||||
if !stand.enabled {
|
||||
if let einrichtung {
|
||||
Section {
|
||||
// Auf dem Telefon selbst ist ein QR-Code sinnlos – die
|
||||
// Authenticator-App liegt daneben. Der Link führt
|
||||
// direkt hinein, das Geheimnis steht zum Abtippen da.
|
||||
Link(destination: URL(string: einrichtung.otpauthUri)
|
||||
?? URL(string: "https://example.invalid")!) {
|
||||
Label(String(localized: "zweifaktor.inAppOeffnen"),
|
||||
systemImage: "arrow.up.forward.app")
|
||||
}
|
||||
LabeledContent(String(localized: "zweifaktor.geheimnis")) {
|
||||
Text(einrichtung.secret)
|
||||
.font(.footnote.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "zweifaktor.schrittEins"))
|
||||
} footer: {
|
||||
Text(String(localized: "zweifaktor.geheimnisErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(localized: "zweifaktor.code"), text: $code)
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.monospaced()
|
||||
Button(String(localized: "zweifaktor.aktivieren")) {
|
||||
Task { await aktiviere() }
|
||||
}
|
||||
.disabled(code.count < 6 || vorgang.laeuft)
|
||||
} header: {
|
||||
Text(String(localized: "zweifaktor.schrittZwei"))
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Button(String(localized: "zweifaktor.einrichten")) {
|
||||
Task { await starte() }
|
||||
}
|
||||
.disabled(vorgang.laeuft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let fehler = vorgang.fehler {
|
||||
Section { FehlerZeile(text: fehler) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func starte() async {
|
||||
guard let client = zustand.client else { return }
|
||||
var neu: ZweiterFaktorEinrichtung?
|
||||
if await vorgang.fuehreAus({ neu = try await client.richteZweitenFaktorEin() }) {
|
||||
einrichtung = neu
|
||||
}
|
||||
}
|
||||
|
||||
private func aktiviere() async {
|
||||
guard let client = zustand.client else { return }
|
||||
var codes: Rettungscodes?
|
||||
let geglueckt = await vorgang.fuehreAus {
|
||||
codes = try await client.aktiviereZweitenFaktor(code: code)
|
||||
}
|
||||
if geglueckt {
|
||||
code = ""
|
||||
einrichtung = nil
|
||||
rettungscodes = codes?.codes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Rettungscodes – einmal zu sehen, danach nie wieder.
|
||||
///
|
||||
/// Deshalb lässt sich dieses Blatt nicht wegwischen: Wer es versehentlich
|
||||
/// schliesst, bekommt neue nur mit dem Kontopasswort am Rechner.
|
||||
private struct RettungscodeBlatt: View {
|
||||
let codes: [String]
|
||||
let fertig: () -> Void
|
||||
|
||||
@State private var abgehakt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(codes, id: \.self) { code in
|
||||
Text(code)
|
||||
.font(.body.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "zweifaktor.rettungscodes"))
|
||||
} footer: {
|
||||
Text(String(localized: "zweifaktor.rettungscodesErklaerung"))
|
||||
}
|
||||
|
||||
Section {
|
||||
ShareLink(item: codes.joined(separator: "\n")) {
|
||||
Label(String(localized: "zweifaktor.codesSichern"),
|
||||
systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Toggle(String(localized: "zweifaktor.codesNotiert"), isOn: $abgehakt)
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "zweifaktor.aktiv"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.interactiveDismissDisabled()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "allgemein.fertig"), action: fertig)
|
||||
.disabled(!abgehakt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
@@ -26,6 +27,7 @@ enum Ablage {
|
||||
static let serverAdresse = "certain.serverAdresse"
|
||||
static let marke = "certain.marke"
|
||||
static let benutzername = "certain.benutzername"
|
||||
static let pin = "certain.pin"
|
||||
}
|
||||
|
||||
// MARK: - Server-Adresse
|
||||
@@ -71,9 +73,54 @@ enum Ablage {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bildschirmsperre
|
||||
|
||||
/// Die PIN selbst wird nie abgelegt, nur ein gesalzener SHA-256 davon.
|
||||
/// Das Salz liegt daneben, beides im Schlüsselbund als `salz:hash` in Hex.
|
||||
///
|
||||
/// Die PIN verdeckt die Oberfläche, sie verschlüsselt nicht die
|
||||
/// Gerätemarke – wer das Telefon entsperrt hat und es auslesen kann,
|
||||
/// käme an die Marke. Gegen Mitlesen über die Schulter und gegen ein
|
||||
/// kurz liegengelassenes Telefon hilft sie, gegen Forensik nicht.
|
||||
static var hatPIN: Bool { schluesselbundLesen(Schluessel.pin) != nil }
|
||||
|
||||
static func setzePIN(_ pin: String) {
|
||||
var salz = Data(count: 16)
|
||||
_ = salz.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
schluesselbundSchreiben(Schluessel.pin, "\(hex(salz)):\(hex(verhashe(pin, salz: salz)))")
|
||||
}
|
||||
|
||||
static func stimmtPIN(_ pin: String) -> Bool {
|
||||
guard let abgelegt = schluesselbundLesen(Schluessel.pin) else { return false }
|
||||
let teile = abgelegt.split(separator: ":", maxSplits: 1)
|
||||
guard teile.count == 2, let salz = daten(hex: String(teile[0])) else { return false }
|
||||
return hex(verhashe(pin, salz: salz)) == String(teile[1])
|
||||
}
|
||||
|
||||
static func loeschePIN() { schluesselbundLoeschen(Schluessel.pin) }
|
||||
|
||||
private static func verhashe(_ pin: String, salz: Data) -> Data {
|
||||
Data(SHA256.hash(data: salz + Data(pin.utf8)))
|
||||
}
|
||||
|
||||
private static func hex(_ daten: Data) -> String {
|
||||
daten.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func daten(hex text: String) -> Data? {
|
||||
guard text.count % 2 == 0 else { return nil }
|
||||
var roh = Data(); var rest = Substring(text)
|
||||
while !rest.isEmpty {
|
||||
guard let wert = UInt8(rest.prefix(2), radix: 16) else { return nil }
|
||||
roh.append(wert); rest = rest.dropFirst(2)
|
||||
}
|
||||
return roh
|
||||
}
|
||||
|
||||
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
|
||||
static func raeumeAuf() {
|
||||
marke = nil
|
||||
loeschePIN()
|
||||
benutzername = nil
|
||||
serverAdresse = nil
|
||||
Zwischenspeicher.leere()
|
||||
|
||||
@@ -370,4 +370,106 @@ extension CertainClient {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ Vollzugang. Wer eine Ausnahme braucht, trägt sie bewusst in `project.yml` ein.
|
||||
| `Buchungen/` | Buchungsliste mit Suche, Formular zum Anlegen und Ändern |
|
||||
| `Abos/` | Verträge und Abos |
|
||||
| `Arbeitsliste/` | Eingang, Übertragsvorschläge, Dubletten |
|
||||
| `Nebenraeume/` | Kategorien, Schlagwörter, Bank, zweiter Faktor |
|
||||
| `Konten/` | Kontenliste und Konto-Formular |
|
||||
| `Texte/` | Übersetzungen, Deutsch und Englisch zeilengleich |
|
||||
| `Tests/` | Logiktests ohne Netz, plus die Modellprüfung gegen `Fixtures/antworten.json` |
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"must_change_password": false,
|
||||
"must_change_password_reason": null,
|
||||
"totp_enabled": false,
|
||||
"created_at": "2026-10-21T19:21:28.289884",
|
||||
"created_at": "2026-10-21T19:57:08.443998",
|
||||
"mfa_setup_required": false
|
||||
},
|
||||
"einstellungen": [
|
||||
@@ -129,7 +129,7 @@
|
||||
"is_archived": false,
|
||||
"excluded_from_stats": false,
|
||||
"spending_is_good": false,
|
||||
"keyword_count": 0
|
||||
"keyword_count": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
@@ -1339,7 +1339,7 @@
|
||||
"fx_rate": null,
|
||||
"counter_amount_cents": null,
|
||||
"counter_currency": null,
|
||||
"created_at": "2026-10-21T19:21:28.451708"
|
||||
"created_at": "2026-10-21T19:57:08.586391"
|
||||
}
|
||||
],
|
||||
"dubletten": {
|
||||
@@ -1400,5 +1400,19 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"schlagwoerter_der_kategorie": [
|
||||
{
|
||||
"id": 1,
|
||||
"category_id": 1,
|
||||
"word": "supermarkt",
|
||||
"amount_cents": null
|
||||
}
|
||||
],
|
||||
"markierteSchlagwoerter": [],
|
||||
"zweiterFaktor": {
|
||||
"enabled": false,
|
||||
"recovery_codes_left": 0
|
||||
},
|
||||
"bankverbindungen": []
|
||||
}
|
||||
|
||||
@@ -744,3 +744,77 @@ extension ServerantwortenTests {
|
||||
#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,6 +11,9 @@
|
||||
"allgemein.unbekannt" = "Unbekannt";
|
||||
"allgemein.sichern" = "Sichern";
|
||||
"allgemein.ok" = "OK";
|
||||
"allgemein.fertig" = "Fertig";
|
||||
"allgemein.bearbeiten" = "Bearbeiten";
|
||||
"allgemein.loeschen" = "Löschen";
|
||||
|
||||
/* --- Reiter --- */
|
||||
"reiter.dashboard" = "Übersicht";
|
||||
@@ -271,3 +274,104 @@
|
||||
"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,6 +11,9 @@
|
||||
"allgemein.unbekannt" = "Unknown";
|
||||
"allgemein.sichern" = "Save";
|
||||
"allgemein.ok" = "OK";
|
||||
"allgemein.fertig" = "Done";
|
||||
"allgemein.bearbeiten" = "Edit";
|
||||
"allgemein.loeschen" = "Delete";
|
||||
|
||||
/* --- Tabs --- */
|
||||
"reiter.dashboard" = "Overview";
|
||||
@@ -271,3 +274,104 @@
|
||||
"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";
|
||||
|
||||
+46
-12
@@ -6,7 +6,7 @@ haben: was entschieden ist, was fertig ist, was als Nächstes dran ist, und was
|
||||
ich über das Backend herausgefunden habe. Lies sie ganz, bevor du anfängst —
|
||||
die Abschnitte „Was das Backend vorgibt" und „Fallen" ersparen dir Tage.
|
||||
|
||||
Stand: 21.10.2026, Commit `9edf610`. 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 4 ✅
|
||||
## 5. Was fertig ist — Stufen 1 bis 5 ✅
|
||||
|
||||
### Stufe 1 (Commit `6b86a6b`)
|
||||
|
||||
@@ -434,6 +434,39 @@ 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
|
||||
@@ -446,19 +479,19 @@ erweiterst.**
|
||||
|
||||
## 6. Was als Nächstes dran ist
|
||||
|
||||
**Stufe 5 ist dran.** Alle vier Backend-Ergänzungen sind drin; von hier an
|
||||
**Stufe 6 ist dran.** Alle vier Backend-Ergänzungen sind drin; von hier an
|
||||
reicht die API, wie sie ist.
|
||||
|
||||
Was bewusst nicht mitkam: das **Logo** eines Vertrags (Upload und Zuschnitt,
|
||||
`POST /subscriptions/{id}/logo`), die **Reihenfolge** der Konten
|
||||
(`PUT /accounts/order`) und das **Lösen** einer Übertrags-Verknüpfung
|
||||
(`DELETE /uebertraege/{link_id}`) — in der Arbeitsliste gibt es nur noch
|
||||
nichts Verknüpftes zu lösen. Alles drei ist am Rechner bequemer und hielt
|
||||
niemanden auf.
|
||||
(`PUT /accounts/order`), das **Lösen** einer Übertrags-Verknüpfung
|
||||
(`DELETE /uebertraege/{link_id}`), die **Transfer-Schlagwörter**
|
||||
(`/categories/transfer-keywords`) und das **Anwenden** von Regeln auf
|
||||
Bestehendes (`POST /categories/keywords/anwenden`). Alles am Rechner
|
||||
bequemer, nichts davon hielt jemanden auf.
|
||||
|
||||
**Stufe 5 — Nebenräume.** Kategorien, Tags, Schlagwörter, Einstellungen,
|
||||
Bankabruf anstossen. Hier gehört auch das **Einrichten** des zweiten Faktors
|
||||
hin — bisher verweist die App dafür in den Browser.
|
||||
Weiter aussen vor, wie von Anfang an entschieden: Bank **verbinden**,
|
||||
Postfach einrichten, Sicherung und Wiederherstellung, Benutzerverwaltung.
|
||||
|
||||
**Stufe 6 — Erweiterungen.** Teilen-Menü und Kurzbefehle. Brauchen eigene Ziele
|
||||
im Xcode-Projekt, eine **App-Gruppe** und eine **geteilte
|
||||
@@ -474,8 +507,9 @@ Vorlagen, Verlauf mit Rücknahme.
|
||||
### Zum Umfang, ehrlich
|
||||
|
||||
Die Weboberfläche hat rund 8900 Zeilen JSX. Nativ wird das nicht weniger.
|
||||
Stufe 2 hat rund 1800 Zeilen gebraucht, Stufe 3 rund 1200, Stufe 4 rund 900;
|
||||
Stufen 5 und 6 zusammen sind grob eine Woche, Stufe 7 nochmal zwei bis drei.
|
||||
Stufe 2 hat rund 1800 Zeilen gebraucht, Stufe 3 rund 1200, Stufe 4 rund 900,
|
||||
Stufe 5 rund 1500; Stufe 6 ist überschaubar, Stufe 7 nochmal zwei bis drei
|
||||
Wochen.
|
||||
|
||||
Der **409-Fall** („nachfragen", mit `trotzdem=true` wiederholbar) ist in
|
||||
Stufe 4 angekommen und erledigt — siehe dort. In den Stufen 5 bis 7 kommt er
|
||||
|
||||
+18
-3
@@ -28,20 +28,32 @@ targets:
|
||||
- path: Einstellungen
|
||||
- path: Konten
|
||||
- path: Modelle
|
||||
- path: Nebenraeume
|
||||
- path: Netzwerk
|
||||
# Deutsch und Englisch, zeilengleich gehalten.
|
||||
- path: Texte
|
||||
# Icon-Composer-Buendel (Ordner). Als Datei einhaengen, sonst zerlegt
|
||||
# XcodeGen es in eine Gruppe und actool findet das Icon nicht.
|
||||
- path: AppIcon.icon
|
||||
type: file
|
||||
buildPhase: resources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.certain
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
# Signing automatisch; wer mit einem anderen Apple-Konto baut, traegt
|
||||
# hier seine Team-ID ein (Xcode: Signing & Capabilities -> Team).
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: PP34X97WS3
|
||||
MARKETING_VERSION: "0.1"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
# iPhone und iPad, zweispaltig gedacht.
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
# Nur iPhone, nur Hochformat - am Rechner gibt es die Weboberflaeche.
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
SUPPORTED_PLATFORMS: "iphoneos iphonesimulator"
|
||||
SUPPORTS_MACCATALYST: NO
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.finance"
|
||||
# Sprachfassung 5: der Code ist nicht auf die strikte
|
||||
# Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen,
|
||||
# wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind.
|
||||
@@ -51,7 +63,10 @@ targets:
|
||||
INFOPLIST_KEY_CFBundleDisplayName: Certain
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait"
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"
|
||||
# Ohne diesen Text stuerzt die App beim ersten Face-ID-Versuch ab.
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription: "Damit entsperrst du Certain, ohne deine PIN zu tippen."
|
||||
|
||||
CertainTests:
|
||||
type: bundle.unit-test
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function Categories() {
|
||||
editing,
|
||||
form: editing
|
||||
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
|
||||
: { name: "", kind: "expense", icon: "list", color: "#3f51b5" },
|
||||
: { name: "", kind: "expense", icon: "list", color: "#3f9c72" },
|
||||
});
|
||||
}
|
||||
function openCategory(groupId, editing = null) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useToast } from "../toast";
|
||||
//
|
||||
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
|
||||
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
|
||||
const STANDARDFARBE = "#3f51b5";
|
||||
const STANDARDFARBE = "#3f9c72";
|
||||
|
||||
export default function Tags() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
Reference in New Issue
Block a user