feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

View File

@@ -0,0 +1,96 @@
import SwiftUI
/// Buttons per `DESIGN (1).md` Components Buttons: "Solid #000000
/// background with #FFFFFF text for primary actions. 1px solid #000000
/// border with no fill for secondary. All buttons are rectangular [0px
/// radius] with no padding-inline under 24px." And Elevation: "On hover,
/// elements do not lift; they shift color... a button fill turns from Black
/// to Slate Blue-Grey" the pressed state here plays the role "hover" plays
/// on the web.
/// Every Zenith button style dims to this opacity when `.disabled(true)`
/// centralised here so a disabled `.zenithPrimary` etc. dims automatically,
/// the way `.borderedProminent`/`.bordered` do out of the box. Without this,
/// each call site would need its own manual `.opacity(isEnabled ? 1 : 0.4)`,
/// which is easy to forget (an earlier pass of this app's screens did, in
/// fact, forget it in a couple of places before this was added centrally).
private let zenithDisabledOpacity: Double = 0.4
struct ZenithPrimaryButtonStyle: ButtonStyle {
var isDestructive = false
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.zenithBodyMd)
.fontWeight(.medium)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, minHeight: 50)
.padding(.horizontal, ZenithSpacing.md)
.background(fill(pressed: configuration.isPressed))
// Sharp corners are the point of this system a plain
// Rectangle fill, never `.cornerRadius`.
.opacity(isEnabled ? 1 : zenithDisabledOpacity)
}
private func fill(pressed: Bool) -> Color {
if isDestructive { return pressed ? Color.zenithError.opacity(0.8) : .zenithError }
return pressed ? .zenithSlate : .zenithBlack
}
}
struct ZenithSecondaryButtonStyle: ButtonStyle {
var isDestructive = false
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.zenithBodyMd)
.fontWeight(.medium)
.foregroundStyle(isDestructive ? Color.zenithError : Color.zenithBlack)
.frame(maxWidth: .infinity, minHeight: 50)
.padding(.horizontal, ZenithSpacing.md)
.background(Color.zenithSurface)
.overlay(
// Border THICKENS on press instead of the fill lifting
// same "no lift, shift instead" rule as the primary style.
Rectangle()
.strokeBorder(
isDestructive ? Color.zenithError : Color.zenithBlack,
lineWidth: configuration.isPressed ? 2 : 1
)
)
.opacity(isEnabled ? 1 : zenithDisabledOpacity)
}
}
/// A plain-text button with no fill/border at all for tertiary actions
/// ("Noch kein Konto? Registrieren", "Abbrechen") that shouldn't compete
/// visually with a screen's primary/secondary buttons.
struct ZenithPlainButtonStyle: ButtonStyle {
var color: Color = .zenithMuted
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.zenithBodySm)
.foregroundStyle(configuration.isPressed ? color.opacity(0.6) : color)
.opacity(isEnabled ? 1 : zenithDisabledOpacity)
}
}
extension ButtonStyle where Self == ZenithPrimaryButtonStyle {
static var zenithPrimary: ZenithPrimaryButtonStyle { ZenithPrimaryButtonStyle() }
static var zenithPrimaryDestructive: ZenithPrimaryButtonStyle { ZenithPrimaryButtonStyle(isDestructive: true) }
}
extension ButtonStyle where Self == ZenithSecondaryButtonStyle {
static var zenithSecondary: ZenithSecondaryButtonStyle { ZenithSecondaryButtonStyle() }
static var zenithSecondaryDestructive: ZenithSecondaryButtonStyle { ZenithSecondaryButtonStyle(isDestructive: true) }
}
extension ButtonStyle where Self == ZenithPlainButtonStyle {
static var zenithPlain: ZenithPlainButtonStyle { ZenithPlainButtonStyle() }
}

View File

@@ -0,0 +1,35 @@
import SwiftUI
/// Cards per `DESIGN (1).md` Components Cards: "White backgrounds with
/// 1px borders in #E2E8F0. No shadows. Content within cards should follow
/// the 24px internal padding rule." And Shapes: 0px radius everywhere.
struct ZenithCardModifier: ViewModifier {
var padding: CGFloat = ZenithSpacing.cardPadding
func body(content: Content) -> some View {
content
.padding(padding)
.background(Color.zenithSurface)
.overlay(Rectangle().strokeBorder(Color.zenithBorder, lineWidth: 1))
// Deliberately no `.cornerRadius` and no `.shadow` both are
// explicitly rejected by the design system ("rejects traditional
// shadows and depth", "0px radius" everywhere).
}
}
extension View {
func zenithCard(padding: CGFloat = ZenithSpacing.cardPadding) -> some View {
modifier(ZenithCardModifier(padding: padding))
}
/// Strips the default iOS `List`/`Form` chrome (rounded row groups,
/// system grey background, inset grouping) toward the flat,
/// bordered-card look the rest of this design system uses. Apply to a
/// `List`/`Form`; still combine with `.listRowBackground(Color.zenithSurface)`
/// and `.listRowSeparatorTint(Color.zenithBorder)` on individual rows if
/// you want row separators to match instead of disappearing.
func zenithListBackground() -> some View {
scrollContentBackground(.hidden)
.background(Color.zenithBg)
}
}

View File

@@ -0,0 +1,81 @@
import SwiftUI
/// The web app's "Zenith Silver" palette (see `tailwind.config.ts`'s `zenith`
/// color group and `DESIGN (1).md` in the main repo root), ported 1:1 as hex
/// values this is a single fixed light palette, not a light/dark pair: the
/// web app itself has no dark mode, and "Architectural Minimalist" is
/// deliberately monochromatic, so inventing a dark variant here would not be
/// matching the web app's design, it would be a different one. If dark mode
/// is wanted later, that's a deliberate follow-up design decision, not a gap
/// in this port.
extension Color {
init(zenithHex hex: UInt32) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: 1
)
}
// MARK: - Base surfaces
/// Page background "Zenith Silver" itself.
static let zenithBg = Color(zenithHex: 0xF6F9FF)
/// Card/container fill Level 1 in the system's elevation model.
static let zenithSurface = Color(zenithHex: 0xFFFFFF)
static let zenithDim = Color(zenithHex: 0xD4DBE2)
static let zenithLow = Color(zenithHex: 0xEEF4FC)
static let zenithContainer = Color(zenithHex: 0xE8EEF6)
static let zenithHigh = Color(zenithHex: 0xE3E9F1)
static let zenithHighest = Color(zenithHex: 0xDDE3EB)
// MARK: - Structure
/// The one border color used everywhere at 1px (2px on an active/focused
/// control see `ZenithTextField`).
static let zenithBorder = Color(zenithHex: 0xE2E8F0)
static let zenithBorderDark = Color(zenithHex: 0xC4C7C9)
// MARK: - Type
static let zenithText = Color(zenithHex: 0x161C22)
static let zenithMuted = Color(zenithHex: 0x444749)
static let zenithSubtle = Color(zenithHex: 0x747779)
// MARK: - Ink & accent
/// Pure black primary buttons, headline type, structural strokes.
static let zenithBlack = Color(zenithHex: 0x000000)
/// Slate blue-grey secondary actions, metadata, the color a primary
/// button's fill shifts to on a pressed/hover state instead of "lifting".
static let zenithSlate = Color(zenithHex: 0x475569)
static let zenithAccent = Color(zenithHex: 0x1E293B)
static let zenithError = Color(zenithHex: 0xBA1A1A)
// MARK: - Status tiers
//
// Ported directly from `src/components/dashboard/StatusBadge.tsx`
// (`getStatusTierMeta`) these are standard Tailwind emerald/amber/blue
// shades, NOT part of the Zenith Silver monochrome palette itself. The
// web app deliberately allows exactly these three status colors as the
// one place the otherwise-monochrome system admits color, so this app
// should use them only for the same purpose (receipt status), not as a
// general accent.
static let zenithScannedBg = Color(zenithHex: 0xECFDF5)
static let zenithScannedText = Color(zenithHex: 0x065F46)
static let zenithScannedBorder = Color(zenithHex: 0xA7F3D0)
static let zenithScannedDot = Color(zenithHex: 0x10B981)
static let zenithPendingBg = Color(zenithHex: 0xFFFBEB)
static let zenithPendingText = Color(zenithHex: 0x92400E)
static let zenithPendingBorder = Color(zenithHex: 0xFDE68A)
static let zenithPendingDot = Color(zenithHex: 0xF59E0B)
static let zenithConfirmedBg = Color(zenithHex: 0xEFF6FF)
static let zenithConfirmedText = Color(zenithHex: 0x1D4ED8)
static let zenithConfirmedBorder = Color(zenithHex: 0xBFDBFE)
static let zenithConfirmedDot = Color(zenithHex: 0x2563EB)
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
/// A 1px rule in the system's one border color `DESIGN (1).md`
/// Components Lists: "Separated by 1px horizontal dividers in #E2E8F0."
/// Prefer this over the bare `Divider()`, whose color/weight follow the
/// system theme rather than this app's fixed palette.
struct ZenithDivider: View {
var body: some View {
Rectangle()
.fill(Color.zenithBorder)
.frame(height: 1)
}
}

View File

@@ -0,0 +1,24 @@
import CoreGraphics
/// The web app's 4px-base spacing scale (`DESIGN (1).md` Layout & Spacing:
/// `spacing.unit: 4px`, `stack-sm/md/lg/xl`, `margin-mobile`). Use these
/// instead of ad-hoc padding numbers so spacing stays consistent with the
/// web app's rhythm.
enum ZenithSpacing {
/// The 4px base unit itself for the rare one-off that doesn't fit a
/// named step below.
static let unit: CGFloat = 4
static let xs: CGFloat = 8 // stack-sm
static let sm: CGFloat = 16 // margin-mobile
static let md: CGFloat = 24 // stack-md / card internal padding
static let lg: CGFloat = 48 // stack-lg
static let xl: CGFloat = 80 // stack-xl
/// Card internal padding `DESIGN (1).md` Components Cards:
/// "Content within cards should follow the 24px internal padding rule."
static let cardPadding: CGFloat = md
/// Screen-edge margin on a phone-width layout (mobile margin, not the
/// 64px desktop margin, which doesn't apply on iPhone).
static let screenMargin: CGFloat = sm
}

View File

@@ -0,0 +1,125 @@
import SwiftUI
/// Ported from `src/components/dashboard/StatusBadge.tsx` the web
/// dashboard's "3-tier status badges" (see `PROJECT.md`'s feature #12).
/// Keep this resolver logic in sync with `resolveReceiptStatusTier` /
/// `receiptNeedsAttention` (`src/lib/ai/recalculate.ts`) on the web side.
enum ReceiptStatusTier {
case scanned
case pendingReview
case confirmed
/// Mirrors `resolveReceiptStatusTier`: a manual confirmation always wins,
/// then math/confidence problems route to "needs review", and everything
/// else that at least parsed successfully is "scanned".
static func resolve(_ receipt: Receipt) -> ReceiptStatusTier {
let validation = receipt.validation
if validation.userConfirmed == true { return .confirmed }
if validation.needsUserReview || !validation.isMathValid { return .pendingReview }
if receipt.status == .needsReview { return .pendingReview }
return .scanned
}
var label: String {
switch self {
case .scanned: return "Erfasst"
case .pendingReview: return "Prüfung erforderlich"
case .confirmed: return "Bestätigt"
}
}
var systemImage: String {
switch self {
case .scanned: return "checkmark.circle.fill"
case .pendingReview: return "exclamationmark.triangle.fill"
case .confirmed: return "person.fill.checkmark"
}
}
var background: Color {
switch self {
case .scanned: return .zenithScannedBg
case .pendingReview: return .zenithPendingBg
case .confirmed: return .zenithConfirmedBg
}
}
var foreground: Color {
switch self {
case .scanned: return .zenithScannedText
case .pendingReview: return .zenithPendingText
case .confirmed: return .zenithConfirmedText
}
}
var border: Color {
switch self {
case .scanned: return .zenithScannedBorder
case .pendingReview: return .zenithPendingBorder
case .confirmed: return .zenithConfirmedBorder
}
}
var dot: Color {
switch self {
case .scanned: return .zenithScannedDot
case .pendingReview: return .zenithPendingDot
case .confirmed: return .zenithConfirmedDot
}
}
}
/// The badge itself small rectangular (0-radius), bordered, dot + icon +
/// label, exactly the shape `StatusBadge.tsx` renders on the web.
struct ZenithStatusBadge: View {
let tier: ReceiptStatusTier
var showIcon = true
var showDot = true
init(_ receipt: Receipt, showIcon: Bool = true, showDot: Bool = true) {
self.tier = .resolve(receipt)
self.showIcon = showIcon
self.showDot = showDot
}
init(tier: ReceiptStatusTier, showIcon: Bool = true, showDot: Bool = true) {
self.tier = tier
self.showIcon = showIcon
self.showDot = showDot
}
var body: some View {
HStack(spacing: 6) {
if showDot {
Circle().fill(tier.dot).frame(width: 6, height: 6)
}
if showIcon {
Image(systemName: tier.systemImage)
.font(.system(size: 10, weight: .semibold))
}
Text(tier.label)
.lineLimit(1)
}
.font(.zenithLabelSm)
.foregroundStyle(tier.foreground)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(tier.background)
.overlay(Rectangle().strokeBorder(tier.border, lineWidth: 1))
}
}
/// A generic (non-status) rectangular chip `DESIGN (1).md` Components
/// Chips/Tags: "`label-md` typography, small rectangular boxes with #F5F7F9
/// fills and no borders." Use for categories, payment methods, etc.
struct ZenithChip: View {
let text: String
var body: some View {
Text(text)
.zenithLabelMdStyle(color: .zenithMuted)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.zenithLow)
}
}

View File

@@ -0,0 +1,79 @@
import SwiftUI
/// Input fields per `DESIGN (1).md` Components Input Fields: "1px solid
/// #E2E8F0 bottom-border only... Use `label-caps` for field labels placed
/// strictly above the input." Border thickens to 2px while focused the
/// same "shift, don't lift" interaction rule as `ZenithButtonStyle`.
///
/// Use this instead of a bare `TextField` + `.textFieldStyle(.roundedBorder)`
/// everywhere in the app; `.roundedBorder` fights this design system
/// directly (it's rounded and iOS-chrome-grey, the opposite of "sharp,
/// monochrome, architectural").
struct ZenithTextField: View {
let label: String
@Binding var text: String
var placeholder: String = ""
var isSecure = false
var keyboardType: UIKeyboardType = .default
var textContentType: UITextContentType?
var autocapitalization: TextInputAutocapitalization = .sentences
var autocorrectionDisabled = false
@FocusState private var isFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: ZenithSpacing.unit) {
Text(label).zenithLabelCapsStyle()
Group {
if isSecure {
SecureField(placeholder, text: $text)
} else {
TextField(placeholder, text: $text)
}
}
.font(.zenithBodyMd)
.foregroundStyle(.zenithText)
.keyboardType(keyboardType)
.textContentType(textContentType)
.textInputAutocapitalization(autocapitalization)
.autocorrectionDisabled(autocorrectionDisabled)
.focused($isFocused)
.padding(.vertical, ZenithSpacing.unit * 2)
.overlay(alignment: .bottom) {
Rectangle()
.fill(isFocused ? Color.zenithBlack : Color.zenithBorder)
.frame(height: isFocused ? 2 : 1)
}
}
}
}
/// A numeric variant bound to an optional `Double`, for amount fields free
/// text with comma-or-dot decimal entry (matches how receipts are actually
/// typed in German usage) rather than a strict `TextField(value:format:)`
/// that rejects a comma outright.
struct ZenithAmountField: View {
let label: String
@Binding var value: Double?
var placeholder: String = "0,00"
private var textProxy: Binding<String> {
Binding(
get: { value.map { String(format: "%.2f", $0) } ?? "" },
set: { newValue in
let normalized = newValue.replacingOccurrences(of: ",", with: ".")
value = Double(normalized)
}
)
}
var body: some View {
ZenithTextField(
label: label,
text: textProxy,
placeholder: placeholder,
keyboardType: .decimalPad
)
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
/// The web app's three-font type system (see `DESIGN (1).md` Typography,
/// and `tailwind.config.ts`'s `fontFamily`), ported with the SAME font
/// files: `Resources/Fonts/*.ttf` are the real Hanken Grotesk / Inter /
/// JetBrains Mono variable fonts (downloaded from the canonical
/// google/fonts repository the same open-source files the web app loads
/// from Google Fonts' CDN), registered via `UIAppFonts` in `project.yml`.
///
/// PostScript names below were read directly out of each font file's `name`
/// table (not guessed) see the font files themselves if these ever need
/// re-verifying after a font update.
///
/// - Hanken Grotesk headlines, set tight (negative tracking) at large
/// sizes for the "locked, architectural" feel the design system asks for.
/// - Inter body copy.
/// - JetBrains Mono labels, captions, technical/numeric data. `labelCaps`
/// is additionally uppercased with wide tracking apply
/// `.zenithLabelCapsStyle()`, not just the bare font, or the case/tracking
/// won't happen (SwiftUI doesn't derive that from the font itself).
enum ZenithFont {
static let display = "HankenGrotesk-Regular"
static let body = "Inter-Regular"
static let mono = "JetBrainsMono-Regular"
}
extension Font {
/// Rarely needed on iPhone (72pt) kept for completeness / a future
/// iPad or marketing surface.
static var zenithDisplayLg: Font { .custom(ZenithFont.display, size: 72).weight(.bold) }
/// Screen/section titles. Sized at the web system's "headline-lg-mobile"
/// (32/40) rather than the 48pt desktop size, since this app is
/// iPhone-only (see project.yml's TARGETED_DEVICE_FAMILY).
static var zenithHeadlineLg: Font { .custom(ZenithFont.display, size: 32).weight(.semibold) }
/// Card/sub-section titles.
static var zenithHeadlineMd: Font { .custom(ZenithFont.display, size: 24).weight(.medium) }
static var zenithBodyLg: Font { .custom(ZenithFont.body, size: 18) }
/// Default body/UI text size.
static var zenithBodyMd: Font { .custom(ZenithFont.body, size: 16) }
static var zenithBodySm: Font { .custom(ZenithFont.body, size: 14) }
/// Field labels, eyebrows, section headers use with
/// `.zenithLabelCapsStyle()` for the uppercase + tracking, not bare.
static var zenithLabelCaps: Font { .custom(ZenithFont.mono, size: 12).weight(.medium) }
/// Chips, technical/tabular data (amounts, dates, IDs).
static var zenithLabelMd: Font { .custom(ZenithFont.mono, size: 14) }
static var zenithLabelSm: Font { .custom(ZenithFont.mono, size: 12) }
}
/// Complete text styles (font + tracking + default color) for each type
/// role. Tracking values are the design system's em values
/// (`DESIGN (1).md` Typography) converted to points AT EACH ROLE'S OWN
/// size `SwiftUI.tracking(_:)` takes absolute points, not em, so these
/// can't be a single generic modifier parameterized only by a ratio.
/// Prefer these over composing `.font(.zenith...)` by hand.
extension View {
/// Screen/section titles (32pt, -0.02em -0.64pt tracking).
func zenithHeadlineLgStyle(color: Color = .zenithText) -> some View {
font(.zenithHeadlineLg).tracking(-0.64).foregroundStyle(color)
}
/// Card/sub-section titles (24pt, -0.01em -0.24pt tracking).
func zenithHeadlineMdStyle(color: Color = .zenithText) -> some View {
font(.zenithHeadlineMd).tracking(-0.24).foregroundStyle(color)
}
/// Default body text (16pt, 0 tracking).
func zenithBodyStyle(color: Color = .zenithText) -> some View {
font(.zenithBodyMd).foregroundStyle(color)
}
func zenithBodySmStyle(color: Color = .zenithMuted) -> some View {
font(.zenithBodySm).foregroundStyle(color)
}
/// `label-caps` role: JetBrains Mono, uppercase, wide tracking
/// (12pt, 0.1em 1.2pt). This is the style used above every
/// `ZenithTextField` and for chip/badge/eyebrow text.
func zenithLabelCapsStyle(color: Color = .zenithSubtle) -> some View {
font(.zenithLabelCaps)
.textCase(.uppercase)
.tracking(1.2)
.foregroundStyle(color)
}
/// Technical/tabular data amounts, dates, IDs (14pt, 0 tracking).
func zenithLabelMdStyle(color: Color = .zenithText) -> some View {
font(.zenithLabelMd).foregroundStyle(color)
}
}