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)
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
|
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
|
||||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
@@ -431,7 +431,7 @@ class Category(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||||
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
|
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
|
||||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
@@ -1312,7 +1312,7 @@ class Tag(Base):
|
|||||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f9c72")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ class TagOut(BaseModel):
|
|||||||
class TagCreate(BaseModel):
|
class TagCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=60)
|
name: str = Field(min_length=1, max_length=60)
|
||||||
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
||||||
color: str = "#6b7480"
|
color: str = "#3f9c72"
|
||||||
|
|
||||||
|
|
||||||
class TagUpdate(BaseModel):
|
class TagUpdate(BaseModel):
|
||||||
@@ -406,7 +406,7 @@ class CategoryGroupCreate(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
kind: CategoryKind = CategoryKind.expense
|
kind: CategoryKind = CategoryKind.expense
|
||||||
icon: str = "list"
|
icon: str = "list"
|
||||||
color: str = "#6b7480"
|
color: str = "#3f9c72"
|
||||||
|
|
||||||
|
|
||||||
class CategoryGroupUpdate(BaseModel):
|
class CategoryGroupUpdate(BaseModel):
|
||||||
@@ -420,7 +420,7 @@ class CategoryCreate(BaseModel):
|
|||||||
group_id: int
|
group_id: int
|
||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
icon: str = "more-horizontal"
|
icon: str = "more-horizontal"
|
||||||
color: str = "#6b7480"
|
color: str = "#3f9c72"
|
||||||
excluded_from_stats: bool = False
|
excluded_from_stats: bool = False
|
||||||
spending_is_good: bool = False
|
spending_is_good: bool = False
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -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`.**
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import LocalAuthentication
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Der Sperrbildschirm. Face ID zuerst, die PIN als Rückfallweg – und als
|
||||||
|
/// einziger Weg, wenn das Gerät keine Biometrie hat oder sie abgelehnt wurde.
|
||||||
|
///
|
||||||
|
/// Die Sperre verdeckt die Oberfläche; die Gerätemarke bleibt im
|
||||||
|
/// Schlüsselbund wie zuvor (siehe `Ablage.hatPIN`). Nach fünf falschen PINs
|
||||||
|
/// meldet die App ab, sonst liesse sich eine sechsstellige PIN in Ruhe
|
||||||
|
/// durchprobieren.
|
||||||
|
struct SperrAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@State private var pin = ""
|
||||||
|
@State private var meldung: String?
|
||||||
|
@State private var biometrieLaeuft = false
|
||||||
|
@FocusState private var imFeld: Bool
|
||||||
|
|
||||||
|
/// Face ID, Touch ID oder gar nichts – entscheidet Beschriftung und Symbol.
|
||||||
|
private var biometrie: LABiometryType {
|
||||||
|
let pruefer = LAContext()
|
||||||
|
guard pruefer.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
|
||||||
|
error: nil) else { return .none }
|
||||||
|
return pruefer.biometryType
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 28) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 44, weight: .medium))
|
||||||
|
.foregroundStyle(Color.marke)
|
||||||
|
|
||||||
|
Text(String(localized: "sperre.titel"))
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
SecureField(String(localized: "sperre.pin"), text: $pin)
|
||||||
|
.textContentType(.password)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.monospaced()
|
||||||
|
.font(.title3)
|
||||||
|
.focused($imFeld)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12,
|
||||||
|
style: .continuous))
|
||||||
|
.onChange(of: pin) { _, neu in
|
||||||
|
pin = String(neu.filter(\.isNumber).prefix(Ziffern.anzahl))
|
||||||
|
if pin.count == Ziffern.anzahl { pruefe() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let meldung {
|
||||||
|
Text(meldung)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
|
||||||
|
if biometrie != .none {
|
||||||
|
Button {
|
||||||
|
Task { await versucheBiometrie() }
|
||||||
|
} label: {
|
||||||
|
Label(biometrieName, systemImage: biometrieSymbol)
|
||||||
|
}
|
||||||
|
.disabled(biometrieLaeuft)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(String(localized: "sperre.abmelden"), role: .destructive) {
|
||||||
|
Task { await zustand.meldeAb() }
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.task { await versucheBiometrie() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var biometrieName: String {
|
||||||
|
biometrie == .touchID
|
||||||
|
? String(localized: "sperre.touchid")
|
||||||
|
: String(localized: "sperre.faceid")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var biometrieSymbol: String {
|
||||||
|
biometrie == .touchID ? "touchid" : "faceid"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fragt Face ID. Jede Ablehnung – abgebrochen, nicht erkannt, nicht
|
||||||
|
/// eingerichtet – führt schlicht zum PIN-Feld; das ist der Rückfallweg.
|
||||||
|
private func versucheBiometrie() async {
|
||||||
|
guard biometrie != .none, !biometrieLaeuft else {
|
||||||
|
imFeld = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
biometrieLaeuft = true
|
||||||
|
defer { biometrieLaeuft = false }
|
||||||
|
let pruefer = LAContext()
|
||||||
|
// Der Systemdialog bietet sonst „Passcode eingeben" an – das wäre der
|
||||||
|
// Gerätecode, nicht unsere PIN, und damit verwirrend.
|
||||||
|
pruefer.localizedFallbackTitle = ""
|
||||||
|
let geglueckt = (try? await pruefer.evaluatePolicy(
|
||||||
|
.deviceOwnerAuthenticationWithBiometrics,
|
||||||
|
localizedReason: String(localized: "sperre.grund"))) ?? false
|
||||||
|
if geglueckt {
|
||||||
|
zustand.entsperrt()
|
||||||
|
} else {
|
||||||
|
imFeld = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruefe() {
|
||||||
|
guard Ablage.stimmtPIN(pin) else {
|
||||||
|
pin = ""
|
||||||
|
Task {
|
||||||
|
let weiter = await zustand.falschePIN()
|
||||||
|
if weiter {
|
||||||
|
meldung = String(format: String(localized: "sperre.falsch"),
|
||||||
|
zustand.verbleibendeVersuche)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meldung = nil
|
||||||
|
zustand.entsperrt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Ziffern {
|
||||||
|
/// Sechs Stellen, wie der Gerätecode und wie der zweite Faktor.
|
||||||
|
static let anzahl = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PIN festlegen oder ändern. Beim ersten Mal führt kein Weg daran vorbei –
|
||||||
|
/// darum gibt es dann kein „Abbrechen".
|
||||||
|
struct PINEinrichtenAnsicht: View {
|
||||||
|
@Environment(AppZustand.self) private var zustand
|
||||||
|
@Environment(\.dismiss) private var schliesse
|
||||||
|
/// Beim ersten Einrichten Pflicht, beim Ändern aus den Einstellungen nicht.
|
||||||
|
var pflicht = true
|
||||||
|
|
||||||
|
@State private var neue = ""
|
||||||
|
@State private var wiederholung = ""
|
||||||
|
@State private var meldung: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
SecureField(String(localized: "pin.neu"), text: $neue)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.monospaced()
|
||||||
|
.onChange(of: neue) { _, wert in
|
||||||
|
neue = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl))
|
||||||
|
}
|
||||||
|
SecureField(String(localized: "pin.wiederholen"), text: $wiederholung)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.monospaced()
|
||||||
|
.onChange(of: wiederholung) { _, wert in
|
||||||
|
wiederholung = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl))
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
// Keine Überschrift: Die steht schon in der Leiste oben.
|
||||||
|
Text(String(localized: "pin.erklaerung"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let meldung {
|
||||||
|
Section {
|
||||||
|
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(String(localized: "pin.sichern"), action: lege)
|
||||||
|
.disabled(neue.count < Ziffern.anzahl
|
||||||
|
|| wiederholung.count < Ziffern.anzahl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String(localized: "pin.einrichtenTitel"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
if !pflicht {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lege() {
|
||||||
|
guard neue.count == Ziffern.anzahl else {
|
||||||
|
meldung = String(localized: "pin.zuKurz"); return
|
||||||
|
}
|
||||||
|
guard neue == wiederholung else {
|
||||||
|
meldung = String(localized: "pin.ungleich")
|
||||||
|
wiederholung = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zustand.setzePIN(neue)
|
||||||
|
if !pflicht { schliesse() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,22 @@ final class AppZustand {
|
|||||||
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
|
/// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe.
|
||||||
private(set) var beschaeftigt = false
|
private(set) var beschaeftigt = false
|
||||||
|
|
||||||
|
// MARK: - Bildschirmsperre
|
||||||
|
|
||||||
|
/// Liegt der Sperrbildschirm über der App?
|
||||||
|
private(set) var gesperrt = false
|
||||||
|
/// Ob eine PIN eingerichtet ist. Als Eigenschaft und nicht direkt aus der
|
||||||
|
/// Ablage gelesen, damit die Ansicht nach dem Einrichten neu zeichnet.
|
||||||
|
private(set) var hatPIN = Ablage.hatPIN
|
||||||
|
private var seitWannWeg: Date?
|
||||||
|
private var fehlversuche = 0
|
||||||
|
|
||||||
|
/// So lange darf man weg sein, ohne erneut zu entsperren.
|
||||||
|
private static let gnadenfrist: TimeInterval = 60
|
||||||
|
/// Danach fliegt die Marke raus und es geht zur Anmeldung. Ohne Grenze
|
||||||
|
/// wäre eine sechsstellige PIN in Ruhe durchprobierbar.
|
||||||
|
private static let maxFehlversuche = 5
|
||||||
|
|
||||||
var serverAdresse: URL? { Ablage.serverAdresse }
|
var serverAdresse: URL? { Ablage.serverAdresse }
|
||||||
|
|
||||||
// MARK: - Start
|
// MARK: - Start
|
||||||
@@ -54,6 +70,9 @@ final class AppZustand {
|
|||||||
}
|
}
|
||||||
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
|
let neuer = CertainClient(basis: adresse, marke: Ablage.marke)
|
||||||
client = neuer
|
client = neuer
|
||||||
|
// Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm.
|
||||||
|
hatPIN = Ablage.hatPIN
|
||||||
|
gesperrt = hatPIN
|
||||||
guard Ablage.marke != nil else {
|
guard Ablage.marke != nil else {
|
||||||
lage = .brauchtAnmeldung
|
lage = .brauchtAnmeldung
|
||||||
return
|
return
|
||||||
@@ -237,6 +256,9 @@ final class AppZustand {
|
|||||||
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
|
let adresse = adresseBehalten ? Ablage.serverAdresse : nil
|
||||||
Ablage.raeumeAuf()
|
Ablage.raeumeAuf()
|
||||||
Ablage.serverAdresse = adresse
|
Ablage.serverAdresse = adresse
|
||||||
|
hatPIN = false
|
||||||
|
gesperrt = false
|
||||||
|
fehlversuche = 0
|
||||||
if let adresse {
|
if let adresse {
|
||||||
client = CertainClient(basis: adresse)
|
client = CertainClient(basis: adresse)
|
||||||
lage = .brauchtAnmeldung
|
lage = .brauchtAnmeldung
|
||||||
@@ -246,6 +268,48 @@ final class AppZustand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sperren und entsperren
|
||||||
|
|
||||||
|
/// iOS fotografiert die App beim Wegschalten für den App-Umschalter.
|
||||||
|
/// Darum wird sofort gesperrt und nicht erst beim Zurückkommen – sonst
|
||||||
|
/// stünden die Kontostände im Umschalter.
|
||||||
|
func gehtWeg() {
|
||||||
|
guard hatPIN, !gesperrt else { return }
|
||||||
|
seitWannWeg = Date()
|
||||||
|
gesperrt = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kurz weggewischt – etwa für eine eintreffende Nachricht – geht ohne
|
||||||
|
/// Nachfrage wieder auf. Erst nach der Gnadenfrist wird gefragt.
|
||||||
|
func kommtZurueck() {
|
||||||
|
guard let weg = seitWannWeg else { return }
|
||||||
|
seitWannWeg = nil
|
||||||
|
if Date().timeIntervalSince(weg) <= Self.gnadenfrist { gesperrt = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
func entsperrt() {
|
||||||
|
gesperrt = false
|
||||||
|
fehlversuche = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet einen Fehlversuch. Gibt zurück, ob noch Versuche übrig sind;
|
||||||
|
/// beim letzten meldet die App ab.
|
||||||
|
func falschePIN() async -> Bool {
|
||||||
|
fehlversuche += 1
|
||||||
|
guard fehlversuche >= Self.maxFehlversuche else { return true }
|
||||||
|
await meldeAb()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var verbleibendeVersuche: Int { max(0, Self.maxFehlversuche - fehlversuche) }
|
||||||
|
|
||||||
|
func setzePIN(_ pin: String) {
|
||||||
|
Ablage.setzePIN(pin)
|
||||||
|
hatPIN = true
|
||||||
|
gesperrt = false
|
||||||
|
fehlversuche = 0
|
||||||
|
}
|
||||||
|
|
||||||
func aktualisiereEinstellungen() async {
|
func aktualisiereEinstellungen() async {
|
||||||
guard let client else { return }
|
guard let client else { return }
|
||||||
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
if let frisch = try? await client.einstellungen() { einstellungen = frisch }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct CertainApp: App {
|
struct CertainApp: App {
|
||||||
@State private var zustand = AppZustand()
|
@State private var zustand = AppZustand()
|
||||||
|
@Environment(\.scenePhase) private var phase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
|
// Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App
|
||||||
@@ -19,6 +20,19 @@ struct CertainApp: App {
|
|||||||
.environment(zustand)
|
.environment(zustand)
|
||||||
.task { await zustand.starte() }
|
.task { await zustand.starte() }
|
||||||
.preferredColorScheme(schema)
|
.preferredColorScheme(schema)
|
||||||
|
// Einmal hier, damit jeder Knopf, Schalter und Link
|
||||||
|
// die Markenfarbe traegt statt des System-Blaus.
|
||||||
|
.tint(.marke)
|
||||||
|
.onChange(of: phase) { _, neue in
|
||||||
|
// `inactive` kommt, bevor iOS das Bild für den
|
||||||
|
// App-Umschalter macht. Dort soll der Sperrbildschirm
|
||||||
|
// stehen, nicht der Kontostand.
|
||||||
|
switch neue {
|
||||||
|
case .inactive, .background: zustand.gehtWeg()
|
||||||
|
case .active: zustand.kommtZurueck()
|
||||||
|
@unknown default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,18 @@ 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
|
||||||
/// erledigt `.sidebarAdaptable` von selbst.
|
/// erledigt `.sidebarAdaptable` von selbst.
|
||||||
|
|||||||
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.
|
/// Übersetzt die Symbolnamen aus der Datenbank in SF Symbols.
|
||||||
///
|
///
|
||||||
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
|
/// Die Weboberfläche benutzt Lucide-Namen und hält dafür ein eigenes
|
||||||
|
|||||||
@@ -183,11 +183,6 @@ struct SymbolUndFarbeFeld: View {
|
|||||||
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
|
"film", "music", "wifi", "phone", "gift", "briefcase", "refresh-ccw",
|
||||||
]
|
]
|
||||||
|
|
||||||
private static let farben = [
|
|
||||||
"#3f51b5", "#2e7d55", "#b3541e", "#8e24aa", "#c62828",
|
|
||||||
"#0277bd", "#6b7480", "#5d4037",
|
|
||||||
]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
|
auswahl(Self.symbole, aktuell: symbol) { symbol = $0 } inhalt: { name in
|
||||||
@@ -196,7 +191,7 @@ struct SymbolUndFarbeFeld: View {
|
|||||||
.foregroundStyle(Color(hex: farbe))
|
.foregroundStyle(Color(hex: farbe))
|
||||||
}
|
}
|
||||||
|
|
||||||
auswahl(Self.farben, aktuell: farbe) { farbe = $0 } inhalt: { wert in
|
auswahl(Farbwahl.palette, aktuell: farbe) { farbe = $0 } inhalt: { wert in
|
||||||
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
|
Circle().fill(Color(hex: wert)).frame(width: 22, height: 22)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ struct BudgetAnsicht: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: "budget.vorlage"), systemImage: "doc.text")
|
Label(String(localized: "budget.vorlage"), systemImage: "doc.text")
|
||||||
}
|
}
|
||||||
.tint(.indigo)
|
.tint(.marke)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
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 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export default function Categories() {
|
|||||||
editing,
|
editing,
|
||||||
form: editing
|
form: editing
|
||||||
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
|
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
|
||||||
: { name: "", kind: "expense", icon: "list", color: "#3f51b5" },
|
: { name: "", kind: "expense", icon: "list", color: "#3f9c72" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function openCategory(groupId, editing = null) {
|
function openCategory(groupId, editing = null) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useToast } from "../toast";
|
|||||||
//
|
//
|
||||||
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
|
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
|
||||||
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
|
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
|
||||||
const STANDARDFARBE = "#3f51b5";
|
const STANDARDFARBE = "#3f9c72";
|
||||||
|
|
||||||
export default function Tags() {
|
export default function Tags() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
Reference in New Issue
Block a user