Compare commits
8
Commits
d290930759
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ec2dc5fd | ||
|
|
4e7f572d93 | ||
|
|
2d1d571b17 | ||
|
|
a079bf406c | ||
|
|
929a735699 | ||
|
|
9195906c2b | ||
|
|
f918297742 | ||
|
|
37517f360b |
@@ -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
|
||||
|
||||
|
||||
+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,9 +28,17 @@ struct RootAnsicht: View {
|
||||
ZweitenFaktorEinrichtenHinweis()
|
||||
|
||||
case .bereit:
|
||||
// Reihenfolge mit Absicht: Ohne PIN kommt niemand an die Zahlen,
|
||||
// und der Sperrbildschirm liegt vor allem anderen.
|
||||
if !zustand.hatPIN {
|
||||
PINEinrichtenAnsicht()
|
||||
} else if zustand.gesperrt {
|
||||
SperrAnsicht()
|
||||
} else {
|
||||
Hauptnavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Reiter. Auf dem iPhone unten, auf dem iPad als Seitenleiste – das
|
||||
|
||||
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: {
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
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 */; };
|
||||
@@ -55,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 */
|
||||
@@ -81,6 +83,7 @@
|
||||
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; };
|
||||
@@ -112,6 +115,7 @@
|
||||
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>"; };
|
||||
@@ -207,6 +211,7 @@
|
||||
DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */,
|
||||
52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */,
|
||||
A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */,
|
||||
3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */,
|
||||
A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */,
|
||||
);
|
||||
path = Anmeldung;
|
||||
@@ -215,6 +220,7 @@
|
||||
BC82A6A323BDD2AD8325BEA2 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D42C757A9D818A524C4CAC8A /* AppIcon.icon */,
|
||||
CB7098BB5F4AEFE48BA9BB36 /* Abos */,
|
||||
B4505C6E527A0020B80AFF04 /* Anmeldung */,
|
||||
BEE6BC97D865C2F0C4EB80C4 /* App */,
|
||||
@@ -395,6 +401,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B551D26D31D9C35898074FE5 /* AppIcon.icon in Resources */,
|
||||
E5F283F445FA9E26CFC15FA5 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -461,6 +468,7 @@
|
||||
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 */,
|
||||
@@ -503,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",
|
||||
@@ -513,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;
|
||||
};
|
||||
@@ -683,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",
|
||||
@@ -693,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;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ struct EinstellungenAnsicht: View {
|
||||
@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 {
|
||||
@@ -27,6 +28,11 @@ struct EinstellungenAnsicht: View {
|
||||
} 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: {
|
||||
@@ -104,6 +110,9 @@ struct EinstellungenAnsicht: View {
|
||||
Text(String(localized: "einstellungen.abmeldenErklaerung"))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $aenderePIN) {
|
||||
PINEinrichtenAnsicht(pflicht: false)
|
||||
}
|
||||
.navigationTitle(String(localized: "reiter.einstellungen"))
|
||||
.refreshable { await lade() }
|
||||
.task { await lade() }
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -25,7 +25,7 @@ struct KategorieFormular: View {
|
||||
|
||||
@State private var name = ""
|
||||
@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 istSparen = false
|
||||
@State private var ohneAuswertung = false
|
||||
@@ -203,7 +203,7 @@ struct GruppenFormular: View {
|
||||
@State private var name = ""
|
||||
@State private var art: Kategorieart = .expense
|
||||
@State private var symbol = "list"
|
||||
@State private var farbe = "#6b7480"
|
||||
@State private var farbe = Farbwahl.standard
|
||||
@State private var fragtNachLoeschen = false
|
||||
@State private var vorbelegt = false
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ struct KategorienAnsicht: View {
|
||||
Label(String(localized: "allgemein.bearbeiten"),
|
||||
systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
.tint(.marke)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
|
||||
@@ -11,11 +11,6 @@ struct TagsAnsicht: View {
|
||||
@State private var bearbeitet: Schlagwort?
|
||||
@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 {
|
||||
List {
|
||||
Section {
|
||||
@@ -77,7 +72,7 @@ struct TagsAnsicht: View {
|
||||
let name = neuerName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return }
|
||||
// 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 {
|
||||
_ = try await client.legeTagAn(TagAnlegen(name: name, color: farbe))
|
||||
}
|
||||
@@ -104,12 +99,9 @@ struct TagFormular: View {
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
@State private var vorgang = Speichervorgang()
|
||||
@State private var name = ""
|
||||
@State private var farbe = "#6b7480"
|
||||
@State private var farbe = Farbwahl.standard
|
||||
@State private var vorbelegt = false
|
||||
|
||||
private static let farben = ["#3f51b5", "#2e7d55", "#b3541e", "#8e24aa",
|
||||
"#c62828", "#0277bd", "#6b7480", "#5d4037"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
@@ -119,7 +111,7 @@ struct TagFormular: View {
|
||||
Section {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Self.farben, id: \.self) { wert in
|
||||
ForEach(Farbwahl.palette, id: \.self) { wert in
|
||||
Button { farbe = wert } label: {
|
||||
Circle()
|
||||
.fill(Color(hex: wert))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -358,3 +358,20 @@
|
||||
"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";
|
||||
|
||||
@@ -358,3 +358,20 @@
|
||||
"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
|
||||
|
||||
+17
-3
@@ -32,17 +32,28 @@ targets:
|
||||
- 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.
|
||||
@@ -52,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