Compare commits

8 Commits
Author SHA1 Message Date
GuidoandClaude Opus 5 09ec2dc5fd feat: Bildschirmsperre mit Face ID und PIN
Beim ersten Start fuehrt kein Weg an einer sechsstelligen PIN vorbei. Danach
sperrt die App beim Kaltstart und beim Wegschalten; kommt man binnen einer
Minute zurueck, geht sie ohne Nachfrage wieder auf. Face ID zuerst, die PIN
als Rueckfallweg und als einziger Weg ohne Biometrie. Nach fuenf falschen
PINs meldet die App ab.

Gesperrt wird schon bei inactive, nicht erst im Hintergrund - sonst stuenden
die Kontostaende im App-Umschalter.

Von der PIN liegt nur ein gesalzener SHA-256 im Schluesselbund. Die
Gerätemarke selbst bleibt wie bisher abgelegt: Die Sperre verdeckt die
Oberflaeche, sie verschluesselt nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 18:09:05 +02:00
GuidoandClaude Opus 5 4e7f572d93 chore: Appicon mit eigenem Dunkelmodus, Projekt auf iPhone-only
Aus Xcode uebernommen: eigener Verlauf fuer den Dunkelmodus im Icon,
Kategorie Finanzen, nur iPhone, Hochformat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:59:20 +02:00
GuidoandClaude Opus 5 2d1d571b17 feat: Gruen ist die Vorgabe fuer Kategorien und Schlagwoerter
Die Farbauswahl der App lag dreimal gleich im Code; sie steht jetzt einmal
in Farbwahl, mit dem Markengruen an erster Stelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:51:49 +02:00
GuidoandClaude Opus 5 a079bf406c feat: Die iOS-App traegt die Markenfarbe statt des System-Blaus
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:45:54 +02:00
GuidoandClaude Opus 5 929a735699 feat: Appicon der iOS-App - Icon-Composer-Buendel eingehaengt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:40:06 +02:00
GuidoandClaude Opus 5 9195906c2b chore: Marke als freigestelltes SVG statt Appicon-Vorlage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:36:51 +02:00
GuidoandClaude Opus 5 f918297742 chore: Appicon-Vorlage als SVG neben der iOS-App
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:33:52 +02:00
GuidoandClaude Opus 5 37517f360b docs: Uebergabe, README und Fahrplan halten Stufe 5 fest
Neu drin: warum der zweite Faktor sich hier einrichten, aber nicht
abschalten laesst (das verlangt das Kontopasswort, und die App speichert
keines - ein Vollzugang auf dem Geraet soll den Schutz nicht aufheben
koennen), warum auf dem Telefon die otpauth-Adresse statt eines QR-Codes
genommen wird, und dass die Rettungscodes genau einmal erscheinen.

Dazu der Grund fuer Einstellungen ohne Sichern-Knopf und der Hinweis, dass
sie dem Konto gehoeren, nicht dem Geraet.

Die Liste dessen, was bewusst am Rechner bleibt, ist jetzt vollstaendig:
Vertragslogo, Kontoreihenfolge, Loesen einer Verknuepfung,
Transfer-Schlagwoerter und das Anwenden von Regeln auf Bestehendes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 22:04:35 +02:00
26 changed files with 566 additions and 51 deletions
+3 -3
View File
@@ -409,7 +409,7 @@ class CategoryGroup(Base):
name: Mapped[str] = mapped_column(String(120), nullable=False) name: Mapped[str] = mapped_column(String(120), nullable=False)
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense) kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list") icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480") color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -431,7 +431,7 @@ class Category(Base):
name: Mapped[str] = mapped_column(String(120), nullable=False) name: Mapped[str] = mapped_column(String(120), nullable=False)
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense) type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal") icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480") color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -1312,7 +1312,7 @@ class Tag(Base):
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
) )
name: Mapped[str] = mapped_column(String(60), nullable=False) name: Mapped[str] = mapped_column(String(60), nullable=False)
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480") color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = ( __table_args__ = (
+3 -3
View File
@@ -361,7 +361,7 @@ class TagOut(BaseModel):
class TagCreate(BaseModel): class TagCreate(BaseModel):
name: str = Field(min_length=1, max_length=60) name: str = Field(min_length=1, max_length=60)
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie. # Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
color: str = "#6b7480" color: str = "#3f9c72"
class TagUpdate(BaseModel): class TagUpdate(BaseModel):
@@ -406,7 +406,7 @@ class CategoryGroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
kind: CategoryKind = CategoryKind.expense kind: CategoryKind = CategoryKind.expense
icon: str = "list" icon: str = "list"
color: str = "#6b7480" color: str = "#3f9c72"
class CategoryGroupUpdate(BaseModel): class CategoryGroupUpdate(BaseModel):
@@ -420,7 +420,7 @@ class CategoryCreate(BaseModel):
group_id: int group_id: int
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
icon: str = "more-horizontal" icon: str = "more-horizontal"
color: str = "#6b7480" color: str = "#3f9c72"
excluded_from_stats: bool = False excluded_from_stats: bool = False
spending_is_good: bool = False spending_is_good: bool = False
+4 -2
View File
@@ -55,9 +55,11 @@ Pflicht-Passwortwechsel, Kontenliste. Stufe 2 ✅: Übersicht, Budget-Monat,
Buchungsliste mit Suche, Vertragsliste. Stufe 3 ✅: Buchungen, Budget-Zeilen, Buchungsliste mit Suche, Vertragsliste. Stufe 3 ✅: Buchungen, Budget-Zeilen,
Verträge und Konten anlegen, ändern und löschen. Stufe 4 ✅: Arbeitsliste mit Verträge und Konten anlegen, ändern und löschen. Stufe 4 ✅: Arbeitsliste mit
Eingang, Übertragsvorschlägen, Gebuchtem ohne Kategorie und Dubletten. 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 entsteht aus `ios/project.yml`, die Modelle werden gegen echte
Serverantworten geprüft. 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`.** **Stand, Entscheidungen und offene Punkte: `ios/UEBERGABE.md`.**
+210
View File
@@ -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() }
}
}
+64
View File
@@ -43,6 +43,22 @@ final class AppZustand {
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe. /// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
private(set) var beschaeftigt = false private(set) var beschaeftigt = false
// MARK: - Bildschirmsperre
/// Liegt der Sperrbildschirm über der App?
private(set) var gesperrt = false
/// Ob eine PIN eingerichtet ist. Als Eigenschaft und nicht direkt aus der
/// Ablage gelesen, damit die Ansicht nach dem Einrichten neu zeichnet.
private(set) var hatPIN = Ablage.hatPIN
private var seitWannWeg: Date?
private var fehlversuche = 0
/// So lange darf man weg sein, ohne erneut zu entsperren.
private static let gnadenfrist: TimeInterval = 60
/// Danach fliegt die Marke raus und es geht zur Anmeldung. Ohne Grenze
/// wäre eine sechsstellige PIN in Ruhe durchprobierbar.
private static let maxFehlversuche = 5
var serverAdresse: URL? { Ablage.serverAdresse } var serverAdresse: URL? { Ablage.serverAdresse }
// MARK: - Start // MARK: - Start
@@ -54,6 +70,9 @@ final class AppZustand {
} }
let neuer = CertainClient(basis: adresse, marke: Ablage.marke) let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
client = neuer client = neuer
// Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm.
hatPIN = Ablage.hatPIN
gesperrt = hatPIN
guard Ablage.marke != nil else { guard Ablage.marke != nil else {
lage = .brauchtAnmeldung lage = .brauchtAnmeldung
return return
@@ -237,6 +256,9 @@ final class AppZustand {
let adresse = adresseBehalten ? Ablage.serverAdresse : nil let adresse = adresseBehalten ? Ablage.serverAdresse : nil
Ablage.raeumeAuf() Ablage.raeumeAuf()
Ablage.serverAdresse = adresse Ablage.serverAdresse = adresse
hatPIN = false
gesperrt = false
fehlversuche = 0
if let adresse { if let adresse {
client = CertainClient(basis: adresse) client = CertainClient(basis: adresse)
lage = .brauchtAnmeldung lage = .brauchtAnmeldung
@@ -246,6 +268,48 @@ final class AppZustand {
} }
} }
// MARK: - Sperren und entsperren
/// iOS fotografiert die App beim Wegschalten für den App-Umschalter.
/// Darum wird sofort gesperrt und nicht erst beim Zurückkommen – sonst
/// stünden die Kontostände im Umschalter.
func gehtWeg() {
guard hatPIN, !gesperrt else { return }
seitWannWeg = Date()
gesperrt = true
}
/// Kurz weggewischt – etwa für eine eintreffende Nachricht – geht ohne
/// Nachfrage wieder auf. Erst nach der Gnadenfrist wird gefragt.
func kommtZurueck() {
guard let weg = seitWannWeg else { return }
seitWannWeg = nil
if Date().timeIntervalSince(weg) <= Self.gnadenfrist { gesperrt = false }
}
func entsperrt() {
gesperrt = false
fehlversuche = 0
}
/// Meldet einen Fehlversuch. Gibt zurück, ob noch Versuche übrig sind;
/// beim letzten meldet die App ab.
func falschePIN() async -> Bool {
fehlversuche += 1
guard fehlversuche >= Self.maxFehlversuche else { return true }
await meldeAb()
return false
}
var verbleibendeVersuche: Int { max(0, Self.maxFehlversuche - fehlversuche) }
func setzePIN(_ pin: String) {
Ablage.setzePIN(pin)
hatPIN = true
gesperrt = false
fehlversuche = 0
}
func aktualisiereEinstellungen() async { func aktualisiereEinstellungen() async {
guard let client else { return } guard let client else { return }
if let frisch = try? await client.einstellungen() { einstellungen = frisch } if let frisch = try? await client.einstellungen() { einstellungen = frisch }
+14
View File
@@ -3,6 +3,7 @@ import SwiftUI
@main @main
struct CertainApp: App { struct CertainApp: App {
@State private var zustand = AppZustand() @State private var zustand = AppZustand()
@Environment(\.scenePhase) private var phase
var body: some Scene { var body: some Scene {
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App // Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
@@ -19,6 +20,19 @@ struct CertainApp: App {
.environment(zustand) .environment(zustand)
.task { await zustand.starte() } .task { await zustand.starte() }
.preferredColorScheme(schema) .preferredColorScheme(schema)
// Einmal hier, damit jeder Knopf, Schalter und Link
// die Markenfarbe traegt statt des System-Blaus.
.tint(.marke)
.onChange(of: phase) { _, neue in
// `inactive` kommt, bevor iOS das Bild für den
// App-Umschalter macht. Dort soll der Sperrbildschirm
// stehen, nicht der Kontostand.
switch neue {
case .inactive, .background: zustand.gehtWeg()
case .active: zustand.kommtZurueck()
@unknown default: break
}
}
} }
} }
} }
+8
View File
@@ -28,9 +28,17 @@ struct RootAnsicht: View {
ZweitenFaktorEinrichtenHinweis() ZweitenFaktorEinrichtenHinweis()
case .bereit: case .bereit:
// Reihenfolge mit Absicht: Ohne PIN kommt niemand an die Zahlen,
// und der Sperrbildschirm liegt vor allem anderen.
if !zustand.hatPIN {
PINEinrichtenAnsicht()
} else if zustand.gesperrt {
SperrAnsicht()
} else {
Hauptnavigation() Hauptnavigation()
} }
} }
}
} }
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das /// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+42
View File
@@ -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"
}
}
+21
View File
@@ -23,6 +23,27 @@ extension Color {
} }
} }
extension Color {
/// Die Markenfarbe: dasselbe Grün wie `--accent` in `web/src/styles.css`,
/// damit App, Weboberfläche und Appicon eine Familie sind. In Dunkel eine
/// Spur heller, sonst säuft das Grün auf dunklem Grund ab.
static let marke = Color(uiColor: UIColor { zug in
zug.userInterfaceStyle == .dark
? UIColor(red: 0.373, green: 0.741, blue: 0.569, alpha: 1) // #5fbd91
: UIColor(red: 0.247, green: 0.612, blue: 0.447, alpha: 1) // #3f9c72
})
}
/// Die Farbauswahl für Kategorien, Schlagwörter und Konten – eine Liste für
/// alle drei Formulare, damit sie nicht auseinanderlaufen. Das Markengrün
/// steht vorn und ist die Vorbelegung; der Rest bleibt bunt, sonst liessen
/// sich die Kategorien in Diagrammen nicht auseinanderhalten.
enum Farbwahl {
static let standard = "#3f9c72"
static let palette = [standard, "#3f51b5", "#b3541e", "#8e24aa",
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
}
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols. /// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
/// ///
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes /// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
+1 -6
View File
@@ -183,11 +183,6 @@ struct SymbolUndFarbeFeld: View {
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw", "film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
] ]
private static let farben = [
"#3f51b5", "#2e7d55", "#b3541e", "#8e24aa", "#c62828",
"#0277bd", "#6b7480", "#5d4037",
]
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
@@ -196,7 +191,7 @@ struct SymbolUndFarbeFeld: View {
.foregroundStyle(Color(hex: farbe)) .foregroundStyle(Color(hex: farbe))
} }
auswahl(Self.farben, aktuell: farbe) { farbe = $0 } inhalt: { wert in auswahl(Farbwahl.palette, aktuell: farbe) { farbe = $0 } inhalt: { wert in
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22) Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
} }
} }
+1 -1
View File
@@ -79,7 +79,7 @@ struct BudgetAnsicht: View {
} label: { } label: {
Label(String(localized: "budget.vorlage"), systemImage: "doc.text") Label(String(localized: "budget.vorlage"), systemImage: "doc.text")
} }
.tint(.indigo) .tint(.marke)
} }
} }
} header: { } header: {
+26 -4
View File
@@ -42,6 +42,7 @@
A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; }; A499FAEF73B54C5533817BAD /* Abo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBA42A030509615199DD45 /* Abo.swift */; };
AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */; }; AFC54370E3CC318193D28E35 /* AuswahlBlaetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */; };
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; }; B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */; };
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = D42C757A9D818A524C4CAC8A /* AppIcon.icon */; };
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; }; B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB439591BFEABB503F2E071 /* RootAnsicht.swift */; };
BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; }; BEC6BF023D4A649003F38A2D /* Endpunkte.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6CEAB46FB8955115A3064B /* Endpunkte.swift */; };
C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; }; C518581A7F62632420821479 /* APIFehler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C59F93E8CD995DFDCABF90 /* APIFehler.swift */; };
@@ -55,6 +56,7 @@
EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; }; EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; };
F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; }; F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; };
F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; }; F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; };
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -81,6 +83,7 @@
3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nebenraeume.swift; sourceTree = "<group>"; }; 3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nebenraeume.swift; sourceTree = "<group>"; };
3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; }; 3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = "<group>"; };
327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; }; 327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = "<group>"; };
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SperrAnsicht.swift; sourceTree = "<group>"; };
3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = "<group>"; }; 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = "<group>"; };
4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; }; 4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = "<group>"; };
416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; }; 416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -112,6 +115,7 @@
C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; }; C5F948B06FF2E65118C82636 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
C841BA87849B2A461449BAF5 /* KategorieFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KategorieFormular.swift; sourceTree = "<group>"; }; 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>"; }; D32F7DD2D6EB6CD5D75DD95D /* ZweiterFaktorAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZweiterFaktorAnsicht.swift; sourceTree = "<group>"; };
D42C757A9D818A524C4CAC8A /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = "<group>"; }; D9FE5CE135C1B9607C47BCE9 /* AppZustand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppZustand.swift; sourceTree = "<group>"; };
DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = "<group>"; }; DBDF1014AC23C916BE571DDC /* Lader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lader.swift; sourceTree = "<group>"; };
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; }; DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnmeldeAnsicht.swift; sourceTree = "<group>"; };
@@ -207,6 +211,7 @@
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */, DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */, 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */, A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */,
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */, A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
); );
path = Anmeldung; path = Anmeldung;
@@ -215,6 +220,7 @@
BC82A6A323BDD2AD8325BEA2 = { BC82A6A323BDD2AD8325BEA2 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D42C757A9D818A524C4CAC8A /* AppIcon.icon */,
CB7098BB5F4AEFE48BA9BB36 /* Abos */, CB7098BB5F4AEFE48BA9BB36 /* Abos */,
B4505C6E527A0020B80AFF04 /* Anmeldung */, B4505C6E527A0020B80AFF04 /* Anmeldung */,
BEE6BC97D865C2F0C4EB80C4 /* App */, BEE6BC97D865C2F0C4EB80C4 /* App */,
@@ -395,6 +401,7 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */,
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */, E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -461,6 +468,7 @@
B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */, B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */,
B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */, B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */,
3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */, 3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */,
F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */,
D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */, D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */,
7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */, 7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */,
25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */, 25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */,
@@ -503,9 +511,12 @@
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Certain; INFOPLIST_KEY_CFBundleDisplayName = Certain;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -513,8 +524,12 @@
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
}; };
name = Release; name = Release;
}; };
@@ -683,9 +698,12 @@
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Certain; INFOPLIST_KEY_CFBundleDisplayName = Certain;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -693,8 +711,12 @@
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.certain;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
}; };
name = Debug; name = Debug;
}; };
@@ -10,6 +10,7 @@ struct EinstellungenAnsicht: View {
@State private var vorgang = Speichervorgang() @State private var vorgang = Speichervorgang()
@State private var zeigeAbmeldefrage = false @State private var zeigeAbmeldefrage = false
@State private var markierte: [MarkiertesWort] = [] @State private var markierte: [MarkiertesWort] = []
@State private var aenderePIN = false
var body: some View { var body: some View {
Form { Form {
@@ -27,6 +28,11 @@ struct EinstellungenAnsicht: View {
} label: { } label: {
Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield") Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield")
} }
Button {
aenderePIN = true
} label: {
Label(String(localized: "pin.aendern"), systemImage: "lock.rotation")
}
} header: { } header: {
Text(String(localized: "einstellungen.zugang")) Text(String(localized: "einstellungen.zugang"))
} footer: { } footer: {
@@ -104,6 +110,9 @@ struct EinstellungenAnsicht: View {
Text(String(localized: "einstellungen.abmeldenErklaerung")) Text(String(localized: "einstellungen.abmeldenErklaerung"))
} }
} }
.sheet(isPresented: $aenderePIN) {
PINEinrichtenAnsicht(pflicht: false)
}
.navigationTitle(String(localized: "reiter.einstellungen")) .navigationTitle(String(localized: "reiter.einstellungen"))
.refreshable { await lade() } .refreshable { await lade() }
.task { await lade() } .task { await lade() }
+3
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+2 -2
View File
@@ -25,7 +25,7 @@ struct KategorieFormular: View {
@State private var name = "" @State private var name = ""
@State private var symbol = "more-horizontal" @State private var symbol = "more-horizontal"
@State private var farbe = "#6b7480" @State private var farbe = Farbwahl.standard
@State private var gruppeId = 0 @State private var gruppeId = 0
@State private var istSparen = false @State private var istSparen = false
@State private var ohneAuswertung = false @State private var ohneAuswertung = false
@@ -203,7 +203,7 @@ struct GruppenFormular: View {
@State private var name = "" @State private var name = ""
@State private var art: Kategorieart = .expense @State private var art: Kategorieart = .expense
@State private var symbol = "list" @State private var symbol = "list"
@State private var farbe = "#6b7480" @State private var farbe = Farbwahl.standard
@State private var fragtNachLoeschen = false @State private var fragtNachLoeschen = false
@State private var vorbelegt = false @State private var vorbelegt = false
+1 -1
View File
@@ -33,7 +33,7 @@ struct KategorienAnsicht: View {
Label(String(localized: "allgemein.bearbeiten"), Label(String(localized: "allgemein.bearbeiten"),
systemImage: "pencil") systemImage: "pencil")
} }
.tint(.indigo) .tint(.marke)
} }
} }
Button { Button {
+3 -11
View File
@@ -11,11 +11,6 @@ struct TagsAnsicht: View {
@State private var bearbeitet: Schlagwort? @State private var bearbeitet: Schlagwort?
@State private var neuerName = "" @State private var neuerName = ""
/// Dieselbe Auswahl wie im Symbol- und Farbfeld, damit die Etiketten zu
/// den Kategorien passen.
private static let farben = ["#3f51b5", "#2e7d55", "#b3541e", "#8e24aa",
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
var body: some View { var body: some View {
List { List {
Section { Section {
@@ -77,7 +72,7 @@ struct TagsAnsicht: View {
let name = neuerName.trimmingCharacters(in: .whitespacesAndNewlines) let name = neuerName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return } guard !name.isEmpty else { return }
// Reihum eine Farbe, damit nicht alle grau sind. Ändern geht danach. // Reihum eine Farbe, damit nicht alle grau sind. Ändern geht danach.
let farbe = Self.farben[zustand.nachschlag.schlagwoerter.count % Self.farben.count] let farbe = Farbwahl.palette[zustand.nachschlag.schlagwoerter.count % Farbwahl.palette.count]
let geglueckt = await vorgang.fuehreAus { let geglueckt = await vorgang.fuehreAus {
_ = try await client.legeTagAn(TagAnlegen(name: name, color: farbe)) _ = try await client.legeTagAn(TagAnlegen(name: name, color: farbe))
} }
@@ -104,12 +99,9 @@ struct TagFormular: View {
@Environment(\.dismiss) private var schliesse @Environment(\.dismiss) private var schliesse
@State private var vorgang = Speichervorgang() @State private var vorgang = Speichervorgang()
@State private var name = "" @State private var name = ""
@State private var farbe = "#6b7480" @State private var farbe = Farbwahl.standard
@State private var vorbelegt = false @State private var vorbelegt = false
private static let farben = ["#3f51b5", "#2e7d55", "#b3541e", "#8e24aa",
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
@@ -119,7 +111,7 @@ struct TagFormular: View {
Section { Section {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) { HStack(spacing: 12) {
ForEach(Self.farben, id: \.self) { wert in ForEach(Farbwahl.palette, id: \.self) { wert in
Button { farbe = wert } label: { Button { farbe = wert } label: {
Circle() Circle()
.fill(Color(hex: wert)) .fill(Color(hex: wert))
+47
View File
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation import Foundation
import Security import Security
@@ -26,6 +27,7 @@ enum Ablage {
static let serverAdresse = "certain.serverAdresse" static let serverAdresse = "certain.serverAdresse"
static let marke = "certain.marke" static let marke = "certain.marke"
static let benutzername = "certain.benutzername" static let benutzername = "certain.benutzername"
static let pin = "certain.pin"
} }
// MARK: - Server-Adresse // MARK: - Server-Adresse
@@ -71,9 +73,54 @@ enum Ablage {
} }
} }
// MARK: - Bildschirmsperre
/// Die PIN selbst wird nie abgelegt, nur ein gesalzener SHA-256 davon.
/// Das Salz liegt daneben, beides im Schlüsselbund als `salz:hash` in Hex.
///
/// Die PIN verdeckt die Oberfläche, sie verschlüsselt nicht die
/// Gerätemarke – wer das Telefon entsperrt hat und es auslesen kann,
/// käme an die Marke. Gegen Mitlesen über die Schulter und gegen ein
/// kurz liegengelassenes Telefon hilft sie, gegen Forensik nicht.
static var hatPIN: Bool { schluesselbundLesen(Schluessel.pin) != nil }
static func setzePIN(_ pin: String) {
var salz = Data(count: 16)
_ = salz.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
schluesselbundSchreiben(Schluessel.pin, "\(hex(salz)):\(hex(verhashe(pin, salz: salz)))")
}
static func stimmtPIN(_ pin: String) -> Bool {
guard let abgelegt = schluesselbundLesen(Schluessel.pin) else { return false }
let teile = abgelegt.split(separator: ":", maxSplits: 1)
guard teile.count == 2, let salz = daten(hex: String(teile[0])) else { return false }
return hex(verhashe(pin, salz: salz)) == String(teile[1])
}
static func loeschePIN() { schluesselbundLoeschen(Schluessel.pin) }
private static func verhashe(_ pin: String, salz: Data) -> Data {
Data(SHA256.hash(data: salz + Data(pin.utf8)))
}
private static func hex(_ daten: Data) -> String {
daten.map { String(format: "%02x", $0) }.joined()
}
private static func daten(hex text: String) -> Data? {
guard text.count % 2 == 0 else { return nil }
var roh = Data(); var rest = Substring(text)
while !rest.isEmpty {
guard let wert = UInt8(rest.prefix(2), radix: 16) else { return nil }
roh.append(wert); rest = rest.dropFirst(2)
}
return roh
}
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt. /// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
static func raeumeAuf() { static func raeumeAuf() {
marke = nil marke = nil
loeschePIN()
benutzername = nil benutzername = nil
serverAdresse = nil serverAdresse = nil
Zwischenspeicher.leere() Zwischenspeicher.leere()
+1
View File
@@ -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 | | `Buchungen/` | Buchungsliste mit Suche, Formular zum Anlegen und Ändern |
| `Abos/` | Verträge und Abos | | `Abos/` | Verträge und Abos |
| `Arbeitsliste/` | Eingang, Übertragsvorschläge, Dubletten | | `Arbeitsliste/` | Eingang, Übertragsvorschläge, Dubletten |
| `Nebenraeume/` | Kategorien, Schlagwörter, Bank, zweiter Faktor |
| `Konten/` | Kontenliste und Konto-Formular | | `Konten/` | Kontenliste und Konto-Formular |
| `Texte/` | Übersetzungen, Deutsch und Englisch zeilengleich | | `Texte/` | Übersetzungen, Deutsch und Englisch zeilengleich |
| `Tests/` | Logiktests ohne Netz, plus die Modellprüfung gegen `Fixtures/antworten.json` | | `Tests/` | Logiktests ohne Netz, plus die Modellprüfung gegen `Fixtures/antworten.json` |
+17
View File
@@ -358,3 +358,20 @@
"bank.abrufFertig" = "Abruf fertig"; "bank.abrufFertig" = "Abruf fertig";
"bank.abrufErgebnis" = "%1$d Umsätze geholt, davon %2$d neu."; "bank.abrufErgebnis" = "%1$d Umsätze geholt, davon %2$d neu.";
"bank.ohneZuordnung" = "Dieser Anbindung ist noch kein Konto zugeordnet. Das geht am Rechner."; "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";
+17
View File
@@ -358,3 +358,20 @@
"bank.abrufFertig" = "Fetch complete"; "bank.abrufFertig" = "Fetch complete";
"bank.abrufErgebnis" = "Fetched %1$d transactions, %2$d of them new."; "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."; "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
View File
@@ -6,7 +6,7 @@ haben: was entschieden ist, was fertig ist, was als Nächstes dran ist, und was
ich über das Backend herausgefunden habe. Lies sie ganz, bevor du anfängst — ich über das Backend herausgefunden habe. Lies sie ganz, bevor du anfängst —
die Abschnitte „Was das Backend vorgibt" und „Fallen" ersparen dir Tage. die Abschnitte „Was das Backend vorgibt" und „Fallen" ersparen dir Tage.
Stand: 21.10.2026, Commit `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`) ### 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. Vorschlag nicht. Die Weboberfläche zeigte den Doppeleintrag genauso.
Behoben in `services/uebertraege.py` (`schlage_vor`), mit Test. 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 #### Prüfdatei-Tests zählen keine Mengen mehr
Die Saat in `erzeuge_ios_fixture.py` wuchs um Eingangseintrag, Dubletten und Die Saat in `erzeuge_ios_fixture.py` wuchs um Eingangseintrag, Dubletten und
@@ -446,19 +479,19 @@ erweiterst.**
## 6. Was als Nächstes dran ist ## 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. reicht die API, wie sie ist.
Was bewusst nicht mitkam: das **Logo** eines Vertrags (Upload und Zuschnitt, Was bewusst nicht mitkam: das **Logo** eines Vertrags (Upload und Zuschnitt,
`POST /subscriptions/{id}/logo`), die **Reihenfolge** der Konten `POST /subscriptions/{id}/logo`), die **Reihenfolge** der Konten
(`PUT /accounts/order`) und das **Lösen** einer Übertrags-Verknüpfung (`PUT /accounts/order`), das **Lösen** einer Übertrags-Verknüpfung
(`DELETE /uebertraege/{link_id}`) — in der Arbeitsliste gibt es nur noch (`DELETE /uebertraege/{link_id}`), die **Transfer-Schlagwörter**
nichts Verknüpftes zu lösen. Alles drei ist am Rechner bequemer und hielt (`/categories/transfer-keywords`) und das **Anwenden** von Regeln auf
niemanden auf. Bestehendes (`POST /categories/keywords/anwenden`). Alles am Rechner
bequemer, nichts davon hielt jemanden auf.
**Stufe 5 — Nebenräume.** Kategorien, Tags, Schlagwörter, Einstellungen, Weiter aussen vor, wie von Anfang an entschieden: Bank **verbinden**,
Bankabruf anstossen. Hier gehört auch das **Einrichten** des zweiten Faktors Postfach einrichten, Sicherung und Wiederherstellung, Benutzerverwaltung.
hin — bisher verweist die App dafür in den Browser.
**Stufe 6 — Erweiterungen.** Teilen-Menü und Kurzbefehle. Brauchen eigene Ziele **Stufe 6 — Erweiterungen.** Teilen-Menü und Kurzbefehle. Brauchen eigene Ziele
im Xcode-Projekt, eine **App-Gruppe** und eine **geteilte im Xcode-Projekt, eine **App-Gruppe** und eine **geteilte
@@ -474,8 +507,9 @@ Vorlagen, Verlauf mit Rücknahme.
### Zum Umfang, ehrlich ### Zum Umfang, ehrlich
Die Weboberfläche hat rund 8900 Zeilen JSX. Nativ wird das nicht weniger. Die Weboberfläche hat rund 8900 Zeilen JSX. Nativ wird das nicht weniger.
Stufe 2 hat rund 1800 Zeilen gebraucht, Stufe 3 rund 1200, Stufe 4 rund 900; 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 5 rund 1500; Stufe 6 ist überschaubar, Stufe 7 nochmal zwei bis drei
Wochen.
Der **409-Fall** („nachfragen", mit `trotzdem=true` wiederholbar) ist in 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 Stufe 4 angekommen und erledigt — siehe dort. In den Stufen 5 bis 7 kommt er
+17 -3
View File
@@ -32,17 +32,28 @@ targets:
- path: Netzwerk - path: Netzwerk
# Deutsch und Englisch, zeilengleich gehalten. # Deutsch und Englisch, zeilengleich gehalten.
- path: Texte - path: Texte
# Icon-Composer-Buendel (Ordner). Als Datei einhaengen, sonst zerlegt
# XcodeGen es in eine Gruppe und actool findet das Icon nicht.
- path: AppIcon.icon
type: file
buildPhase: resources
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.certain PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.certain
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# Signing automatisch; wer mit einem anderen Apple-Konto baut, traegt # Signing automatisch; wer mit einem anderen Apple-Konto baut, traegt
# hier seine Team-ID ein (Xcode: Signing & Capabilities -> Team). # hier seine Team-ID ein (Xcode: Signing & Capabilities -> Team).
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: PP34X97WS3 DEVELOPMENT_TEAM: PP34X97WS3
MARKETING_VERSION: "0.1" MARKETING_VERSION: "0.1"
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "1"
# iPhone und iPad, zweispaltig gedacht. # Nur iPhone, nur Hochformat - am Rechner gibt es die Weboberflaeche.
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1"
SUPPORTED_PLATFORMS: "iphoneos iphonesimulator"
SUPPORTS_MACCATALYST: NO
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.finance"
# Sprachfassung 5: der Code ist nicht auf die strikte # Sprachfassung 5: der Code ist nicht auf die strikte
# Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen, # Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen,
# wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind. # wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind.
@@ -52,7 +63,10 @@ targets:
INFOPLIST_KEY_CFBundleDisplayName: Certain INFOPLIST_KEY_CFBundleDisplayName: Certain
INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait"
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"
# Ohne diesen Text stuerzt die App beim ersten Face-ID-Versuch ab.
INFOPLIST_KEY_NSFaceIDUsageDescription: "Damit entsperrst du Certain, ohne deine PIN zu tippen."
CertainTests: CertainTests:
type: bundle.unit-test type: bundle.unit-test
+1 -1
View File
@@ -187,7 +187,7 @@ export default function Categories() {
editing, editing,
form: editing form: editing
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color } ? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
: { name: "", kind: "expense", icon: "list", color: "#3f51b5" }, : { name: "", kind: "expense", icon: "list", color: "#3f9c72" },
}); });
} }
function openCategory(groupId, editing = null) { function openCategory(groupId, editing = null) {
+1 -1
View File
@@ -17,7 +17,7 @@ import { useToast } from "../toast";
// //
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im // Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat. // Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
const STANDARDFARBE = "#3f51b5"; const STANDARDFARBE = "#3f9c72";
export default function Tags() { export default function Tags() {
const { t } = useTranslation(); const { t } = useTranslation();