From 4f4e98b3250e725c85171588e87f5a0f45df36c7 Mon Sep 17 00:00:00 2001 From: Belli Date: Mon, 17 Aug 2026 08:40:20 -0700 Subject: [PATCH 1/3] docs: add receipt scanning research spike --- Savely/Utilities/OCRUtilities.swift | 118 ------- Savely/Utilities/TextRecognizer.swift | 53 --- .../ViewModels/Camera/CameraViewModel.swift | 180 ----------- .../CameraScanner/TotalConfirmationView.swift | 93 ------ docs/spikes/receipt-scan-spike.md | 305 ++++++++++++++++++ 5 files changed, 305 insertions(+), 444 deletions(-) delete mode 100755 Savely/Utilities/OCRUtilities.swift delete mode 100644 Savely/Utilities/TextRecognizer.swift delete mode 100644 Savely/ViewModels/Camera/CameraViewModel.swift delete mode 100644 Savely/Views/Components/CameraScanner/TotalConfirmationView.swift create mode 100644 docs/spikes/receipt-scan-spike.md diff --git a/Savely/Utilities/OCRUtilities.swift b/Savely/Utilities/OCRUtilities.swift deleted file mode 100755 index cc655ed..0000000 --- a/Savely/Utilities/OCRUtilities.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// OCRUtilities.swift -// Savely -// -// Created by Ivan Lorenzana Belli on 23/10/24. -// - -import UIKit -import Vision -import CoreImage -import CoreImage.CIFilterBuiltins - -func preprocessImage(_ image: UIImage) -> UIImage { - guard let ciImage = CIImage(image: image) else { return image } - - // Convertir a escala de grises - let grayscale = ciImage.applyingFilter("CIPhotoEffectMono") - - // Ajustar el contraste - let adjustedContrast = grayscale.applyingFilter("CIColorControls", parameters: [ - kCIInputContrastKey: 1.5 - ]) - - // Aplicar reducción de ruido - let noiseReduction = adjustedContrast.applyingFilter("CINoiseReduction", parameters: [ - "inputNoiseLevel": 0.02, - "inputSharpness": 0.4 - ]) - - // Convertir de CIImage a UIImage - let context = CIContext() - if let cgImage = context.createCGImage(noiseReduction, from: noiseReduction.extent) { - return UIImage(cgImage: cgImage) - } - - return image -} - -func recognizeText(in image: UIImage) { - guard let cgImage = image.cgImage else { return } - - let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:]) - - let request = VNRecognizeTextRequest { (request, error) in - if let error = error { - print("Error al reconocer texto: \(error.localizedDescription)") - return - } - - guard let observations = request.results as? [VNRecognizedTextObservation] else { return } - - var recognizedText = "" - for observation in observations { - guard let topCandidate = observation.topCandidates(1).first else { continue } - recognizedText += topCandidate.string + "\n" - } - - // Procesar el texto reconocido - print("Texto reconocido:\n\(recognizedText)") - - // Extraer artículos y precios - extractItemsAndPrices(from: recognizedText) - } - - request.recognitionLevel = .accurate - request.usesLanguageCorrection = false // Puedes ajustar esto según tus necesidades - - DispatchQueue.global(qos: .userInitiated).async { - do { - try requestHandler.perform([request]) - } catch { - print("Error al realizar la solicitud de reconocimiento: \(error.localizedDescription)") - } - } -} - -func extractPrice(from line: String) -> String? { - let pricePattern = #"[-]?[\d]+[.,]\d{2}"# - if let regex = try? NSRegularExpression(pattern: pricePattern, options: []) { - let range = NSRange(location: 0, length: line.utf16.count) - if let match = regex.firstMatch(in: line, options: [], range: range), - let priceRange = Range(match.range, in: line) { - return String(line[priceRange]) - } - } - return nil -} - -func extractItemsAndPrices(from text: String) { - let lines = text.components(separatedBy: "\n") - var items: [(name: String, price: String)] = [] - - var currentItemName = "" - for line in lines { - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedLine.isEmpty { continue } - - if let price = extractPrice(from: trimmedLine) { - var name = trimmedLine.replacingOccurrences(of: price, with: "").trimmingCharacters(in: .whitespaces) - if name.isEmpty { - name = currentItemName - } - if !name.isEmpty && !name.allSatisfy({ $0.isNumber || $0.isPunctuation }) { - items.append((name: name, price: price)) - currentItemName = "" - } - } else { - if !trimmedLine.allSatisfy({ $0.isSymbol || $0.isPunctuation }) { - currentItemName = trimmedLine - } - } - } - - // Imprimir los artículos y precios - for item in items { - print("Artículo: \(item.name), Precio: \(item.price)") - } -} diff --git a/Savely/Utilities/TextRecognizer.swift b/Savely/Utilities/TextRecognizer.swift deleted file mode 100644 index 644e659..0000000 --- a/Savely/Utilities/TextRecognizer.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// TextRecognizer.swift -// Savely -// -// Created by Aldair Salazar on 11/13/24. -// - -import Vision -import UIKit - -class TextRecognizer { - - // Esta función acepta una UIImage y un closure que devuelve el texto reconocido - func recognizeText(in image: UIImage, completion: @escaping (String?) -> Void) { - guard let cgImage = image.cgImage else { - completion(nil) - return - } - - let request = VNRecognizeTextRequest { (request, error) in - if let error = error { - print("Error de reconocimiento de texto: \(error)") - completion(nil) - return - } - - guard let observations = request.results as? [VNRecognizedTextObservation] else { - completion(nil) - return - } - - // Recopilar todo el texto reconocido en una sola cadena - let recognizedText = observations.compactMap { observation in - observation.topCandidates(1).first?.string - }.joined(separator: "\n") - - completion(recognizedText.isEmpty ? nil : recognizedText) - } - - request.recognitionLevel = .accurate - - let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) - DispatchQueue.global(qos: .userInitiated).async { - do { - try handler.perform([request]) - } catch { - print("Error al realizar la solicitud de reconocimiento de texto: \(error)") - completion(nil) - } - } - } -} - diff --git a/Savely/ViewModels/Camera/CameraViewModel.swift b/Savely/ViewModels/Camera/CameraViewModel.swift deleted file mode 100644 index 4317367..0000000 --- a/Savely/ViewModels/Camera/CameraViewModel.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// CameraViewModel.swift -// Savely -// -// Created by Ivan Lorenzana Belli on 19/11/24. -// - -import Foundation -import SwiftUI -import Vision -import Combine - -class CameraViewModel: ObservableObject, Identifiable { - @Published var showConfirmation = false - @Published var detectedTotal: String = "" - @Published var alternativeTotals: [String] = [] - @Published var isRectangleDetected: Bool = false - @Published var detectedRectangle: VNRectangleObservation? - @Published var isCameraAvailable: Bool = true - - private let textRecognizer = TextRecognizer() - private var cancellables = Set() - @ObservedObject var cameraManager = CameraManager() - var expenseViewModel: ExpenseTrackerViewModel - - // Necesitamos una referencia a presentationMode - var dismissCameraView: (() -> Void)? - - init(expenseViewModel: ExpenseTrackerViewModel) { - self.expenseViewModel = expenseViewModel - bindCameraManager() - } - - private func bindCameraManager() { - // Observar photoData para procesar la imagen capturada - cameraManager.$photoData - .compactMap { $0 } - .sink { [weak self] data in - self?.processCapturedPhoto(data) - } - .store(in: &cancellables) - - // Observar isRectangleDetected y detectedRectangle - cameraManager.$isRectangleDetected - .receive(on: RunLoop.main) - .assign(to: \.isRectangleDetected, on: self) - .store(in: &cancellables) - - cameraManager.$detectedRectangle - .receive(on: RunLoop.main) - .assign(to: \.detectedRectangle, on: self) - .store(in: &cancellables) - - cameraManager.$isCameraAvailable - .receive(on: RunLoop.main) - .assign(to: \.isCameraAvailable, on: self) - .store(in: &cancellables) - } - - func startSession() { - cameraManager.startSession() - } - - func stopSession() { - cameraManager.stopSession() - } - - func capturePhoto() { - cameraManager.capturePhoto() - } - - private func processCapturedPhoto(_ data: Data) { - if let image = UIImage(data: data) { - textRecognizer.recognizeText(in: image) { [weak self] recognizedText in - guard let self = self else { return } - if let recognizedText = recognizedText { - // print("Texto reconocido: \n\(recognizedText)") - - if let totalAmount = self.extractTotalAmount(from: recognizedText) { - DispatchQueue.main.async { - self.detectedTotal = totalAmount - self.alternativeTotals = self.getAlternativeTotals(from: recognizedText) - self.showConfirmation = true - } - } else { - print("No se encontró un total.") - } - } else { - print("No se reconoció ningún texto en la imagen.") - } - } - } - } - - // Funciones para extraer el total y valores monetarios - private func extractTotalAmount(from text: String) -> String? { - let lines = text.components(separatedBy: .newlines) - var amountsWithIndices: [(amount: Double, index: Int)] = [] - - - // Buscar los índices de las líneas que contienen variantes de "Total" - let totalIndices = lines.enumerated().compactMap { (index, line) -> Int? in - let lowercasedLine = line.lowercased() - if lowercasedLine.contains("total") || lowercasedLine.contains("total:") { - return index - } - return nil - } - - - // Extraer todos los valores monetarios válidos y sus índices - for (index, line) in lines.enumerated() { - let potentialAmounts = extractMonetaryValues(from: line) - for amount in potentialAmounts { - let lowercasedLine = line.lowercased() - if amount >= 10 && amount <= 1000000, - !lowercasedLine.contains("cash") && - !lowercasedLine.contains("change") && - !lowercasedLine.contains("tend") && - !lowercasedLine.contains("debit") { - amountsWithIndices.append((amount: amount, index: index)) - } - } - } - - - // Filtrar los valores que estén cerca de cualquiera de las líneas con "Total" - var nearbyAmounts: [Double] = [] - for totalIndex in totalIndices { - let nearby = amountsWithIndices.filter { abs($0.index - totalIndex) <= 3 } - nearbyAmounts.append(contentsOf: nearby.map { $0.amount }) - } - - - // Si hay valores válidos cercanos a "Total", seleccionar el mayor de ellos - if let probableTotal = nearbyAmounts.max() { - return String(format: "%.2f", probableTotal) - } - - // Si no se encuentran valores cercanos a "Total", usar el valor máximo general como respaldo - return amountsWithIndices.map { $0.amount }.max().map { String(format: "%.2f", $0) } - } - - private func extractMonetaryValues(from text: String) -> [Double] { - var values: [Double] = [] - let regex = try? NSRegularExpression(pattern: "\\d{1,3}(?:[.,]\\d{3})*(?:[.,]\\d{2})", options: []) - let range = NSRange(location: 0, length: text.utf16.count) - - regex?.enumerateMatches(in: text, options: [], range: range) { match, _, _ in - if let match = match { - var valueString = (text as NSString).substring(with: match.range) - valueString = valueString.replacingOccurrences(of: ",", with: "") - valueString = valueString.replacingOccurrences(of: ".", with: ".") - if let value = Double(valueString) { - values.append(value) - } - } - } - - return values - } - - private func getAlternativeTotals(from text: String) -> [String] { - let allAmounts = extractMonetaryValues(from: text) - return Array(allAmounts.prefix(3)).map { String(format: "%.2f", $0) } - } - - // Funciones para manejar la confirmación del total - func confirmTotal(_ total: String) { - expenseViewModel.expenseDescription = "Recibo escaneado" - expenseViewModel.amount = total - expenseViewModel.addExpense() - showConfirmation = false - dismissCameraView?() - } - - func retakePhoto() { - showConfirmation = false - } -} diff --git a/Savely/Views/Components/CameraScanner/TotalConfirmationView.swift b/Savely/Views/Components/CameraScanner/TotalConfirmationView.swift deleted file mode 100644 index 69c1a7c..0000000 --- a/Savely/Views/Components/CameraScanner/TotalConfirmationView.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// TotalConfirmationView.swift -// Savely -// -// Created by Aldair Salazar on 11/14/24. -// -import SwiftUI - - -struct TotalConfirmationView: View { - @State var detectedTotal: String - @State var alternativeTotals: [String] = [] - @State private var isCorrect = true - @State private var selectedTotal: String? = nil - - var onConfirm: (String) -> Void // Callback para confirmar el total seleccionado - var onRetake: () -> Void // Callback para tomar otra foto - - var body: some View { - VStack(spacing: 20) { - Text(Strings.Camera.pleaseConfirmValueTitle) - .font(.headline) - - // Muestra el total detectado o el total seleccionado si el usuario ha elegido uno - TextField("", text: .constant(selectedTotal ?? detectedTotal)) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .disabled(true) - .foregroundColor(.gray) - .multilineTextAlignment(.center) - .padding() - - Text(Strings.Camera.confirmationValueLabel) - .font(.subheadline) - - HStack { - Button(action: { - isCorrect = false - }) { - Text(Strings.Buttons.noButton) - .padding() - .frame(maxWidth: .infinity) - .background(Color.warmInkMuted) - .foregroundStyle(Color.warmOnGreen) - .cornerRadius(10) - } - - Button(action: { - onConfirm(detectedTotal) // Confirma el monto detectado - }) { - Text(Strings.Buttons.yesButton) - .padding() - .frame(maxWidth: .infinity) - .background(Color.warmGreenFill) - .foregroundStyle(Color.warmOnGreen) - .cornerRadius(10) - } - } - - // Si el usuario presionó "No", mostrar todas las opciones alternativas en un ScrollView - if !isCorrect { - ScrollView { - VStack(spacing: 10) { - ForEach(alternativeTotals, id: \.self) { total in - Button(action: { - selectedTotal = total // Selecciona el total alternativo - onConfirm(total) - }) { - Text(total) - .padding() - .frame(maxWidth: .infinity) - .background(Color.gray.opacity(0.1)) - .cornerRadius(10) - } - } - - Button(action: { - onRetake() // Permite tomar otra foto - }) { - Text(Strings.Camera.takeAnotherPhotoLabel) - .padding() - .frame(maxWidth: .infinity) - .background(Color.gray.opacity(0.1)) - .cornerRadius(10) - } - } - .padding(.top) - } - .frame(maxHeight: 200) - } - } - .padding() - } -} diff --git a/docs/spikes/receipt-scan-spike.md b/docs/spikes/receipt-scan-spike.md new file mode 100644 index 0000000..c64f732 --- /dev/null +++ b/docs/spikes/receipt-scan-spike.md @@ -0,0 +1,305 @@ +# Spike — Receipt scanning: where we are, what the industry does, what's free + +**Date:** 2026-08-16 +**Status:** research complete; phase 1 implemented in PR `feature/receipt-scan-parser` (see §7) +**Scope:** the "Scan a receipt" flow (`Managers/CameraManager`, `Utilities/TextRecognizer`, +`ViewModels/Camera/CameraViewModel`, `Views/Components/CameraScanner/*`). +**Method:** code audit with the extraction code executed verbatim against receipt +fixtures; Apple APIs checked against the local iOS SDK `.swiftinterface` files and +developer.apple.com; industry/vendor claims checked against primary pages; eight +load-bearing claims independently re-verified. Everything marked *unverified* is +labelled as such. + +--- + +## 0. TL;DR + +- Today the scanner is **on-device Apple Vision OCR + a fragile string heuristic**. + The OCR engine is fine; the pipeline around it is not: the photo reaches Vision + **without its orientation**, with **English-only** recognition, the amount regex + **drops leading digits** (`1234.56 → 234.56`), totals **under 10 are rejected** + (a coffee), `SUBTOTAL` counts as "total", the exclusion words are English-only so + on the standard Mexican layout `TOTAL / EFECTIVO / CAMBIO` the scanner **saves the + cash tendered, not the total** (fixture: 100.00 instead of 41.18), and the saved + row is `"Recibo escaneado"` with no merchant, no date, no category. +- The industry (Expensify, Zoho, Wave, Dext, Fetch…) is **cloud OCR + parsers + + sometimes humans**, and even Expensify says OCR alone lands "around 85%". Every + cloud option conflicts with Savely's stated promise ("money data lives on the + phone and never leaves it") and none has a free tier that can back a consumer + app. **On-device is Savely's differentiator**; a few indie apps market exactly + that ("no servers, no uploads"). +- **Best free option = Apple's own stack, which we already have.** iOS 26 (our + floor) ships `RecognizeDocumentsRequest` → `DocumentObservation` with lines + + bounding boxes and `detectedData` that includes **`.moneyAmount` (Decimal + + currency) and `.calendarEvent` (dates)**; VisionKit gives a document camera + (perspective correction, auto-capture) and a live scanner with a `.currency` + content type; Foundation Models is an on-device LLM for structured extraction on + Apple-Intelligence devices only. Nothing third-party (ML Kit, Tesseract, VLMs, + cloud receipt APIs) beats it for this app. +- **Plan:** (1) a pure, unit-tested `ReceiptParser` over OCR lines *with geometry* + (row clustering, receipt amount grammar, es/en label hierarchy + blocklist, + scoring, merchant, date, category hint) + fix the Vision call (orientation, + languages, Swift API, single `RecognizeDocumentsRequest`) + lifecycle fixes; + (2) a Warm Meadow review sheet (editable amount, alternatives, merchant, date, + category, thumbnail, honest empty state) + gallery import; (3) later, optional + on-device enhancers (Foundation Models chooser, lens-smudge hint). No cloud. + +--- + +## 1. What we do now (audited 2026-08-16, `dev` @ 61c8aa3) + +### 1.1 Flow + +Two entry points — the "+" sheet (`MainNavigationView.swift:55-75`, `.fullScreenCover(item:)`) +and the Money-tab banner (`ExpenseTrackerView.swift:58-86`, view model created *inside* the +cover closure) → `CameraView` → `CameraManager.startSession()` (hand-rolled `AVCaptureSession`, +photo output + a video output running `VNDetectRectanglesRequest` on every frame whose result +**nothing draws**) → shutter → `photo.fileDataRepresentation()` → `UIImage(data:)` → `.cgImage` +→ `TextRecognizer` (`VNRecognizeTextRequest`, `.accurate`, **no orientation, no languages**) → +top candidates joined with `\n` (**bounding boxes discarded**) → `CameraViewModel.extractTotalAmount` +→ `TotalConfirmationView` (disabled text field + Yes/No + first-3-regex-hits "alternatives") → +`ExpenseTrackerViewModel.addExpense()` with description `"Recibo escaneado"`, `date: Date()`, +`category: nil`. + +### 1.2 Verified failure modes (extraction code run verbatim — `regex_probe.swift`) + +| Input | Today | Should be | Cause | +|---|---|---|---| +| `1234.56` | **234.56** | 1234.56 | regex `\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{2})` is unanchored — leading digits dropped when there is no thousands separator (`CameraViewModel.swift:146`) | +| `SUBTOTAL 12499.00 / IVA 1999.84 / TOTAL 14498.84` | **999.84** | 14498.84 | same bug, then MAX-near-"total" | +| `1.234,56` | discarded | 1234.56 | commas stripped globally → `1.23456` | +| `250`, `TOTAL $250` | nothing | 250 | two decimals mandatory | +| `TOTAL 8.50` (a coffee) | nothing | 8.50 | `amount >= 10` floor (`:116`) | +| OXXO layout `SUBTOTAL 35.50 / IVA 5.68 / TOTAL 41.18 / EFECTIVO 100.00 / CAMBIO 58.82` | **100.00** | 41.18 | picks the MAX within ±3 lines of any line containing "total"; exclusions are English only (`cash/change/tend/debit`) — `EFECTIVO`/`CAMBIO`/`PROPINA`/`IVA`/`SUBTOTAL` are not excluded | +| Restaurant `TOTAL 162.40 / PROPINA 24.36 / TOTAL CON PROPINA 186.76` | 186.76 | 162.40 (+ alternative) | `contains("total")` also matches `SUBTOTAL`, `TOTAL CON PROPINA`, `TOTAL ARTICULOS` | +| Column-split OCR (labels and amounts as separate observations) | SUBTOTAL 35.50 | 41.18 | text is joined in observation order; label and amount are no longer on the same "line" | +| `IVA 16.00%` | 16.00 counted as an amount | ignore | no `%` guard | +| Alternatives | first 3 regex hits (`:163-166`) | ranked candidates | in every fixture above the true total is **not** among them | + +Other correctness issues (file:line): + +- **Orientation lost:** `UIImage(data:).cgImage` drops EXIF; `VNImageRequestHandler(cgImage:options:)` + assumes `.up`; the photo connection's rotation is never set from an + `AVCaptureDevice.RotationCoordinator` (`CameraManagerExtension.swift:16-18`, `TextRecognizer.swift:42`). + Portrait captures reach OCR rotated. (Mechanism certain; magnitude to confirm on device.) +- **English-only OCR:** no `recognitionLanguages`; language correction left on for a numeric document + (`TextRecognizer.swift:40`). +- **Silent failure:** no total / no text / Vision error → `print` only; the shutter shows no + processing state; a failed save through the "+" path shows no alert (`CameraViewModel.swift:85-90`, + `MainNavigationView.swift:73-75`). +- **Crash path:** shutter enabled with no configured session (permission denied, simulator) → + `AVCapturePhotoOutput.capturePhoto` on an output without a connection raises an ObjC exception + (`CameraView.swift:59-66`, `CameraManager.swift:125-128`). +- **Retain cycle:** `.assign(to:on: self)` stored in `self.cancellables` (`CameraViewModel.swift:44-57`) + → every scan session leaks a `CameraViewModel` + `CameraManager` + `AVCaptureSession`. +- **Wasted work:** rectangle detection at ~30 fps published to a view that never reads it; the + request is performed from a concurrent queue with a plain `Bool` guard (data race). +- **Dead code:** `Utilities/OCRUtilities.swift` (118 lines, second divergent price regex, unused + `preprocessImage`). +- **Rules broken:** hardcoded Spanish literal `"Recibo escaneado"` and English literals in + `CameraView`; `.gray` colours, `RoundedBorderTextFieldStyle`, fixed 300×400 card, `.font(.headline)` + instead of `.warmFont`, no accessibility labels on shutter/close (PRODUCT.md WCAG-AA commitment; + DESIGN.md tokens); a **disabled text field showing the amount** ("a dead control is worse than no + control"); Money-tab banner promises "the total and category" while the scanner never sets a category. +- **Untestable:** all extraction is `private` on a VM that instantiates AVFoundation; + `SavelyTests` has zero scanner tests. + +What is *right* today and must be preserved: everything runs on-device, no network. + +--- + +## 2. How the industry does it + +| Product | Technique | UX after capture | Free? | On-device? | +|---|---|---|---|---| +| Expensify SmartScan | OCR + vendor parsers + **human review** when unsure; states OCR alone ≈ 85% | pre-filled expense; "Receipt scanning failed. Enter details manually" | 25 scans/mo | No (2017: receipts seen by MTurk workers) | +| Zoho Expense Autoscan | cloud; date/amount/merchant/currency/payment mode + line items; **remembers category per merchant** | 3 states: scanning / processed / failed-retry-manually; share-sheet import | 20 scans/mo | No | +| Wave, Dext, Shoeboxed | cloud OCR (Shoeboxed adds humans) | "processing" inbox → editable fields → "mark as reviewed" | paid | No | +| Fetch / Ibotta | in-house doc-understanding ML, server-side | best-in-class **capture guidance** (edge guide, lighting tips, long-receipt sections), confirm step | free (they monetise the data) | No | +| Splitwise Pro | cloud; total/date/description | camera-only (no gallery import since 2019), edits were delete-and-recreate | paid | No | +| YNAB, Monefy, Money Manager, Wallet by BudgetBakers | **no OCR** — photo attach at most | — | — | — | +| Apple (Notes, Live Text, iOS 26 Wallet order tracking) | on-device Vision / Apple Intelligence | "tap the text you mean" | built-in | Yes | +| Indie: Recu, "Receipt Scanner & Expense Tracker", Receipts (Nineone), Receipts Space (Mac) | Apple Vision (+ Foundation Models) | highlighted values you tap to confirm; "AI drafts, you decide"; category memory per merchant | one-time / free | **Yes — and they market it** | + +Patterns worth copying: framing guide + light/flat tips → capture (auto or shutter) → +visible *processing* state → **editable review form pre-filled with amount, merchant, date, +category** + receipt thumbnail → save; failure falls to "enter it yourself" instead of a dead end; +nobody shows a fake numeric confidence; gallery/share-sheet import is table stakes. +Anti-patterns seen in the field: camera-only capture, non-editable extracted values, auto-save +without confirmation, generic descriptions when the merchant line is right there. + +Sources: use.expensify.com/blog/mastering-accuracy-for-receipts; help.expensify.com (SmartScan +troubleshooting); zoho.com/expense/help/expenses/autoscan-receipts; waveapps.com/receipts; +help.dext.com; home.ibotta.com; kb.splitwise.com + feedback.splitwise.com; ynab.com/whats-new; +App Store pages for Recu (id6757321214), Receipts (id1584452416); receipts-app.com/help; +qz.com/1141695 (Expensify/MTurk). + +--- + +## 3. Apple's on-device stack (free, already in the SDK) — verified against `iPhoneOS27.0.sdk` interfaces + +| API | Since | What it gives us | Caveat | +|---|---|---|---| +| **`Vision.RecognizeDocumentsRequest` → `DocumentObservation`** | iOS 26 | `document.text.lines: [RecognizedTextObservation]` (with `boundingRegion`, `topCandidates`), `paragraphs`, `tables`, `title`, and **`detectedData: [DataDetectorMatch]`** whose `match.details` includes **`.moneyAmount(amount: Decimal, currency: Locale.Currency)`** and `.calendarEvent`, plus phone/email/address (useful to *exclude* lines from merchant). Options: `recognitionLanguages`, `useLanguageCorrection`, `customWords`, `minimumTextHeightFraction`. Apple's doc names **receipts** as a target. | Forum report (thread 788381): each receipt line becomes its own paragraph and 2-column blocks come back column-by-column → **we must cluster rows ourselves from line boxes**; `tables` rarely appear on thermal receipts. Money detector needs a symbol/code (`$1,234.56`, `MXN 350.00` match; bare `TOTAL 350.00` does **not**) and guesses `$` as the device-region currency → keep a numeric grammar. Perf numbers unpublished. | +| `Vision.RecognizeTextRequest` (Swift API) | iOS 18 | async, `recognitionLanguages: [Locale.Language]`, `supportedRecognitionLanguages` (query at runtime — Spanish + English are supported for `.accurate`), `usesLanguageCorrection`, `customWords`, `perform(on:orientation:)` | Fallback if the document request returns nothing. | +| `DataDetection.DataDetector` / `String.dataDetectorMatches(.moneyAmount)` | iOS 26 | modern NSDataDetector with money + dates + `Options(documentRegion:)` | same symbol requirement | +| **`VisionKit.VNDocumentCameraViewController`** | iOS 13 | edge detection, auto-capture, perspective correction, multi-page; returns `VNDocumentCameraScan.imageOfPage(at:)` upright | zero theming (system chrome, not Warm Meadow); iOS 26 UI regression reports; `isSupported == false` in Simulator | +| `VisionKit.DataScannerViewController` `.text(textContentType: .currency)` | iOS 16 / currency iOS 17 | live "tap the amount" mode; A12+ (every iOS 26 iPhone) | only symbol-tagged amounts, no merchant/date; system highlights | +| `Vision.DetectLensSmudgeRequest` | iOS 26 | 0–1 smudge confidence (Apple sample threshold 0.9) → "wipe your lens" hint | A14+ only (not iPhone 11/SE2) → `try?` | +| **`FoundationModels.SystemLanguageModel`** | iOS 26 | ~3B on-device LLM, `@Generable` constrained decoding, `availability` = `.available` / `.unavailable(.deviceNotEligible \| .appleIntelligenceNotEnabled \| .modelNotReady)`, ~4096-token context, Spanish supported | iPhone 15 Pro/16-family and later **only** — many LatAm users get `.deviceNotEligible`; text-only on iOS 26 (image `Attachment` and Private Cloud Compute are iOS 27, and PCC leaves the phone); can hallucinate numbers → may only *choose among* OCR candidates | +| `PhotosUI.PhotosPicker` | iOS 16 | gallery import with **no** photo-library permission string; also the Simulator/QA path | — | +| `VisionKit.ImageAnalysisInteraction` (Live Text) | iOS 16 | user selects the total by touch on the photo | UIKit interaction; optional polish | + +Verified locally: `DocumentObservation`, `DataDetector.Match.SemanticDetails.moneyAmount`, +`DataScannerViewController.TextContentType.currency (iOS 17)`, `SystemLanguageModel.Availability` +all present in the iOS SDK shipped with Xcode 27 beta; baseline `xcodebuild build` passes on +iPhone 17 Pro simulator. Empirical DataDetection runs on macOS 27 beta (not an iOS 26 device): +`"$1,234.56" → MXN 1234.56` (device region), `"Total MXN 350.00" → MXN 350`, +`"IMPORTE 1.234,56 €" → EUR 1234.56`, `"TOTAL 1,234.56" → no match`, `"14/08/2026 15:32" → calendarEvent`. + +--- + +## 4. Free / open-source / third-party options — evaluated against "never leaves the phone" + +| Option | Cost | On-device | Verdict | +|---|---|---|---| +| **Apple Vision (+ iOS 26 document/money detection)** | free, 0 MB | yes | **Adopt** — already the engine; the gap is orientation/languages/parser | +| **Own `ReceiptParser` porting proven OSS heuristics** (receipt-parser-core, tinvois-parser: keyword tables, first-after-keyword vs max-in-window, merchant list, date regex set) | free | yes | **Adopt** as concepts (not code) | +| **Apple Foundation Models** | free | yes (AI-eligible devices) | **Later** (phase 3), as a chooser over candidate IDs with heuristic fallback | +| Google ML Kit Text Recognition v2 (iOS) | free, +~38 MB, CocoaPods | yes, but SDK **sends usage metrics to Google** (terms) | Reject — no accuracy edge on receipts, telemetry conflicts with premise | +| Tesseract (SwiftyTesseract archived 2022, gali8 = Tesseract 3.03) | free | yes | Reject — unmaintained, worst accuracy on phone photos | +| PaddleOCR / RapidOCR via ONNX / Paddle-Lite | free | yes | Reject — C++ bridge + model conversion for no gain over Vision | +| Core ML KIE models (LayoutLMv3 CC-BY-NC, Donut MIT ~200M params) | free weights | yes | Reject — licence / weeks of conversion / English-only training data | +| Small on-device VLMs (LFM2-VL, SmolVLM, Qwen2-VL 2B via MLX/LEAP; FastVLM is research-only) | free | yes | Reject for now — 1–2 GB download, 1.3–3 GB RAM, 5–10 s/receipt; superseded by Foundation Models | +| Azure Document Intelligence prebuilt-receipt | F0 500 pages/mo (shared by all users) | **no** | Reject — off-device, needs key + proxy | +| Google Document AI Expense, AWS Textract AnalyzeExpense | ~$0.01/page, trial-only free tiers | no | Reject | +| Mindee (free plan discontinued 2025), Veryfi (100/mo, $500 min paid), Taggun (trial), Klippa, Nanonets | business pricing | no | Reject | +| OpenAI gpt-5-nano / gpt-4.1-nano vision (~$0.0001–0.0004 per receipt) | cheap | no | **Not in the default build.** Only conceivable as an explicit per-scan opt-in behind a proxy after rewording the onboarding promise. Note: `Config.plist` (with the OpenAI key) is *currently copied into the app bundle* as a resource even though tips are flagged off — pre-existing issue to fix separately, independent of this spike. | +| Gemini free tier | free but **trains on your data** | no | Reject | + +Public datasets for offline evaluation: SROIE (1,000 English scanned receipts, GT +company/date/address/total; free registration), CORD (Indonesian, CC-BY-4.0, labels subtotal vs +total vs cash/change), WildReceipt (1,740 in-the-wild English receipts, Apache-2.0), +ExpressExpense SRD (200 restaurant receipts, MIT, no field GT). **There is no public Spanish/Mexican +receipt dataset** — Savely must build its own anonymized fixture set. Repo is public → fixtures are +JSON OCR dumps only (no images), with PII lines (RFC, phone, address, card, names) stripped. + +Reported accuracy for context: SROIE Task-3 winner at competition time was lexicon + regex rules +(90.5% F1); fine-tuned LayoutLMv3 ≈ 96.9%; naive LLM prompting ≈ 79–82%; hobby text-only rule +parsers ≈ 63% on totals. → **Layout-aware rules are competitive; LLMs help most as a chooser over +structured candidates.** + +--- + +## 5. Extraction heuristics we will implement (all deterministic, unit-tested) + +1. **Rows** = OCR lines clustered by vertical overlap of their boxes (≥ 50 % of line height), + sorted left→right; a row's text is its lines joined by spaces. Never trust observation order. +2. **Amount grammar** (receipt-specific — deliberately different from `AmountParsing.parseAmount`, + which rejects thousands separators for *typed* input): word-bounded tokens + `[$MX$USD]? 1,234.56 | 1.234,56 | 1234.56 | 1234 | 8.50 | 8,50`; per-token separator + inference (a `,` followed by exactly 3 digits and then `.dd` is a thousands separator; + `1.234,56` is decimal-comma); OCR confusions normalized only inside amount tokens + (`O→0, l/I→1, S→5, B→8`); tokens followed by `%` ignored; date/time-like and long digit + runs (RFC, card, ticket numbers) ignored; currency token captured as a *hint*. + Ambiguous tokens surface as alternatives — never silently pick a ×100/×1000 reading. +3. **Candidate scoring** for the total (each amount gets a score): + - label class on the same row (or the row directly above when the row has no label): + strong positive `TOTAL`, `TOTAL A PAGAR`, `IMPORTE TOTAL`, `IMPORTE`, `GRAN TOTAL`, + `GRAND TOTAL`, `AMOUNT DUE`, `BALANCE DUE`, `TOTAL MXN|USD`, `TOTAL VENTA`; + strong negative `SUBTOTAL`/`SUB-TOTAL`/`SUB TOTAL`, `IVA`/`IEPS`/`TAX`/`VAT`, + `PROPINA`/`TIP`/`GRATUITY`, `EFECTIVO`/`CASH`, `CAMBIO`/`CHANGE`, `TARJETA`/`CARD`/`VISA`/ + `MASTERCARD`/`DEBITO`/`CREDITO`, `PAGO`/`TENDERED`/`RECIBIDO`/`SU PAGO`, `PUNTOS`, + `AHORRO`/`SAVINGS`/`DESCUENTO`/`DISCOUNT`, `TOTAL ARTICULOS`/`ITEMS`, `TOTAL CON PROPINA` + (kept as an alternative); + - bottom-most among total-labelled rows (+), member of the right-aligned amount column (+), + math consistency `subtotal + taxes == x` or `cash − change == x` (+), text height above + the receipt median (+), largest plausible non-excluded amount (small +), duplicate value + elsewhere (card charge repeats the total) (+). + - Output: best candidate + ranked alternatives + a `confidence` (high/medium/low from the + score gap). Low confidence → the review sheet says so and focuses the amount. +4. **Dual-currency border receipts** (`TOTAL MXN … / TOTAL USD … / TIPO DE CAMBIO`): prefer the row + whose currency matches `Locale.current.currency`; the other becomes the first alternative. +5. **Merchant** = most prominent (tallest / top-most / centred) row among the top ~25 % that is not a + date, phone, address, RFC, URL, or a label; brand dictionary + razón-social map + (`CADENA COMERCIAL OXXO → OXXO`, `NUEVA WAL MART DE MEXICO → Walmart`, `TIENDAS SORIANA → Soriana`, + Costco, HEB, Chedraui, Calimax, Smart & Final, 7-Eleven, Starbucks, Uber Eats, Rappi, DiDi Food, + Pemex, Farmacias …). Written to `expenseDescription` (no schema change). +6. **Date** = `dd/mm/yyyy`, `dd-mm-yy`, `yyyy-mm-dd`, `dd/MMM/yyyy`, `d MMM yyyy` with es/en month + names, preferring a row that also carries a time or a `FECHA`/`DATE` label; day/month order + resolved by `>12` first, then by language signal (Spanish labels/`IVA` ⇒ dd/mm; en-US ⇒ mm/dd); + rejected if in the future or older than 2 years → today, flagged for confirmation. +7. **Category hint** from merchant/keywords onto the five stored chips (`Coffee`, `Food`, + `Transit`, `Shopping`, `Other`) — pre-selects the chip; the user's tap wins. +8. **Evaluation:** JSON fixtures `{id, locale, source, lines:[{text, box, confidence}], expected}` + under `SavelyTests/Fixtures/Receipts/`, one parametrized XCTest reporting per-field hits. + Phase-1 fixtures are hand-authored layouts (OXXO, restaurant + tip, Costco no-separator, US + cash/change, column-split, comma-decimal, whole-peso, sub-10) — **they prove behaviour, not + real-world accuracy**. A DEBUG-only "Export OCR dump" action in the review sheet lets the owner + collect real anonymized fixtures; only then are accuracy numbers meaningful. Never fabricate. + +--- + +## 6. Decisions + +| # | Decision | Why | +|---|---|---| +| D1 | **All on-device. No cloud path in the default build.** | PRODUCT.md promise; Expensify/MTurk precedent; free tiers cannot back a consumer app; a shipped key is a security issue. | +| D2 | **Single `RecognizeDocumentsRequest` per capture** (`recognitionLanguages` resolved at runtime from `supportedRecognitionLanguages` → Spanish + English variants; `useLanguageCorrection = false` for a numeric document; `customWords` = label vocabulary), **orientation always passed**, fallback `RecognizeTextRequest`. Rows are clustered by us from `text.lines`; `detectedData` money/date are extra candidate sources, not the truth. | One OCR pass, geometry + detectors from one API on our iOS 26 floor. Correction on/off to be A/B'd once real fixtures exist. | +| D3 | **Pure `ReceiptParser` in `Utilities/`, XCTest fixtures, no Vision/UI dependency** (input = `[OCRLine]`). | Testable on CI simulators without a camera; house style (`GoalPace`, `AutoMoveSuggestion`). | +| D4 | **No SwiftData schema change now.** merchant → `expenseDescription`; receipt date → `ExpenseModel.date` when unambiguous; category → chip; currency shown as a hint only, not stored. Receipt image is **not persisted** (discarded after save). | Zero migration risk; the app has no currency concept yet (every formatter hardcodes `$`) — that is a separate product decision. | +| D5 | **Capture: keep the Warm Meadow custom camera** (fix rotation via `RotationCoordinator`, guard the shutter, remove the unused per-frame rectangle detection, add torch + gallery import) **and ship `VNDocumentCameraViewController` behind `FeatureFlags.useSystemDocumentCamera` (default off)** so the owner can A/B on a real device (Simulator cannot run either camera). | Perspective correction likely improves OCR, but the system sheet breaks Warm Meadow and cannot be evaluated here; both feed the same pipeline. | +| D6 | **Review sheet replaces `TotalConfirmationView`**: thumbnail, editable amount (WarmKeypad), alternative chips, merchant field, date, category chips, "We read this — is it right?" copy, "no clear total" state that focuses the amount and never dead-ends; processing state; single in-flight scan; all strings localizable, `.warmFont`, a11y labels. | Industry pattern; PRODUCT.md principles 1 & 3; DESIGN.md tokens. | +| D7 | Foundation Models, `DetectLensSmudgeRequest`, `DataScannerViewController` live mode, merchant→category memory, line items: **out of this PR** (phase 3 / backlog). | Cannot be tested on the available hardware; parser must be measured first. | +| D8 | Deployment target stays iOS 26.0. | Just set; iOS 27-only APIs are not planned on. | + +Open questions for the owner (do not block phase 1): keep custom camera vs system document camera +after a device check; whether to add a currency concept to the app; usesLanguageCorrection A/B on +real fixtures; whether a small thumbnail should ever be stored. + +--- + +## 7. Plan + +- **Phase 1 (this PR)** — parser + pipeline correctness + review UX: + `Utilities/Receipt/{OCRLine, ReceiptAmount, ReceiptParser, ReceiptVocabulary, ReceiptDates, + ReceiptMerchant}`, `Utilities/ReceiptOCR.swift` (Vision, orientation, languages), + `ViewModels/Camera/ReceiptScanModel.swift` (@Observable, @MainActor; owns capture → processing → + review; single in-flight scan), `Views/Components/CameraScanner/{CameraView, CameraPreview, + ReceiptReviewSheet, DocumentCameraView}`, `CameraManager` fixes, delete `OCRUtilities.swift` + + `TotalConfirmationView.swift`, both entry points migrated, banner/onboarding copy made true, + `SavelyTests/ReceiptParserTests.swift` + `Fixtures/Receipts/*.json`, docs (this file, + `CLAUDE.md` invariant 7 correction, `gotchas.yaml`). + Verification: 16 hand-authored fixtures + unit tests for the grammar/dates/labels/rows, + and `ReceiptOCRIntegrationTests` running the real Vision pipeline on a script-rendered + receipt image (reads total 57.50, merchant, date, subtotal alternative; and the same + bitmap rotated + tagged sideways still reads — the orientation fix). Manual review found + and fixed: TOTAL rows demoted by IVA/PAGADO/TARJETA words, masked card digits offered as + alternatives, column headers lending IMPORTE to the first item, "2 MARGARITAS 26.00" + parsing as a date, detector fallbacks bypassing plausibility rules, session/torch left + running behind the review, VoiceOver reaching the camera under overlays. +- **Phase 2** — owner collects ~30 real es-MX/US OCR dumps with the DEBUG export → commit + anonymized fixtures → tune weights, A/B language correction, decide camera surface. +- **Phase 3** — optional on-device enhancers: Foundation Models chooser (candidate IDs only, + gated on `SystemLanguageModel.default.availability`, timeout, labelled as a draft), + lens-smudge hint (A14+), merchant→category memory. + +--- + +## 8. Sources (primary) + +Apple: developer.apple.com/documentation/vision/recognizedocumentsrequest · +…/vision/documentobservation · …/datadetection/datadetector/match/semanticdetails/moneyamount · +…/vision/recognizetextrequest · …/visionkit/vndocumentcameraviewcontroller · +…/visionkit/datascannerviewcontroller/textcontenttype/currency · …/vision/detectlenssmudgerequest · +…/foundationmodels/systemlanguagemodel · …/foundationmodels/managing-the-context-window · +WWDC25 272 "Read documents using the Vision framework" · WWDC25 286/301 (Foundation Models) · +WWDC23 10048 (VisionKit currency) · support.apple.com/121115 (Apple Intelligence devices/languages) · +developer.apple.com/forums/thread/788381 (RecognizeDocumentsRequest on receipts). +Industry/vendors: see §2; pricing pages: developers.openai.com/api/docs/pricing, +ai.google.dev/gemini-api/docs/pricing, azure.microsoft.com (Document Intelligence), +aws.amazon.com/textract/pricing, docs.mindee.com/account-management/plans, faq.veryfi.com, +taggun.io/pricing, developers.google.com/ml-kit/terms + ios-data-disclosure. +Datasets/heuristics: rrc.cvc.uab.es/?ch=13 (SROIE), github.com/clovaai/cord, WildReceipt (MMOCR), +expressexpense.com SRD, arxiv 2103.10213 (SROIE report), arxiv 2303.05063 (ICL-D3IE), +github.com/ReceiptManager/receipt-parser-legacy, github.com/knipknap/receiptparser. From 62938b66ce5e28b5b069a7af02280429c237fb7d Mon Sep 17 00:00:00 2001 From: Belli Date: Mon, 17 Aug 2026 08:40:40 -0700 Subject: [PATCH 2/3] feat(scan): on-device receipt parser, OCR fixes and a review screen Rewrite the receipt scanner around a pure, fixture-tested ReceiptParser and fix the Vision pipeline around it. All on-device; no network. Parser (Utilities/Receipt/): rows clustered from line boxes, a receipt amount grammar (thousands separators, comma decimals, whole pesos, OCR digit confusions, %/dates/ids ignored), es/en label hierarchy with exclusions (SUBTOTAL, IVA, PROPINA, EFECTIVO, CAMBIO, TARJETA...), scored candidates with alternatives, merchant, printed date, category hint. Fixes the old heuristics that saved EFECTIVO instead of TOTAL, dropped leading digits ("1234.56" -> 234.56) and rejected totals < 10. OCR (Utilities/ReceiptOCR.swift): RecognizeDocumentsRequest with the image orientation passed and es+en recognition languages; Apple's money/date detectors as a second opinion; RecognizeTextRequest fallback. Camera: RotationCoordinator so stills carry the right EXIF orientation, guarded async capture (no crash on an unconfigured session), torch, gallery import, no per-frame rectangle detection; VNDocumentCamera behind FeatureFlags.useSystemDocumentCamera for a device A/B. UI: ReceiptScanFlowView (capture -> reading -> review) replaces the disabled-text-field card: editable amount, alternative chips, merchant, date, category, thumbnail, honest empty/failed states, VoiceOver announcements and modal overlays, Warm Meadow tokens, all strings in Strings.swift with es-419. Tests: 16 JSON fixtures + grammar/date/label unit tests, a real-Vision integration test on a script-rendered receipt (upright and rotated), and a Simulator UI test through the Photos picker. --- .claude/knowledge/gotchas.yaml | 5 + CLAUDE.md | 5 +- Savely.xcodeproj/project.pbxproj | 84 ++- .../Extensions/CameraManagerExtension.swift | 54 +- Savely/Managers/CameraManager.swift | 193 ++++-- Savely/Models/ExpenseModel.swift | 7 +- Savely/Resources/Localizable.xcstrings | 628 ++++++++++++++++++ Savely/Resources/Strings.swift | 142 ++++ Savely/Utilities/FeatureFlags.swift | 9 + Savely/Utilities/Receipt/OCRLine.swift | 71 ++ .../Receipt/ReceiptAmountGrammar.swift | 265 ++++++++ .../Utilities/Receipt/ReceiptDateParser.swift | 167 +++++ Savely/Utilities/Receipt/ReceiptParser.swift | 488 ++++++++++++++ .../Utilities/Receipt/ReceiptVocabulary.swift | 292 ++++++++ Savely/Utilities/ReceiptOCR.swift | 184 +++++ .../ViewModels/Camera/ReceiptScanModel.swift | 273 ++++++++ .../ExpensesTrackerViewModel.swift | 44 +- .../Components/CameraScanner/CameraView.swift | 245 +++++-- .../CameraScanner/DocumentCameraView.swift | 60 ++ .../CameraScanner/ReceiptReviewView.swift | 291 ++++++++ .../CameraScanner/ReceiptScanFlowView.swift | 162 +++++ .../ExpensesTab/ExpenseTrackerView.swift | 14 +- Savely/Views/MainNavigationView.swift | 10 +- SavelyTests/Fixtures/Images/README.md | 6 + .../Fixtures/Images/synthetic-oxxo.png | Bin 0 -> 94503 bytes SavelyTests/Fixtures/Receipts/README.md | 25 + .../Fixtures/Receipts/coffee-under-ten.json | 114 ++++ .../Fixtures/Receipts/column-split.json | 147 ++++ .../Fixtures/Receipts/comma-decimal.json | 143 ++++ .../Receipts/costco-no-separators.json | 178 +++++ .../Receipts/dual-currency-mx-locale.json | 137 ++++ .../Receipts/dual-currency-us-locale.json | 135 ++++ .../Fixtures/Receipts/label-above-amount.json | 143 ++++ .../Fixtures/Receipts/no-total-label.json | 92 +++ .../Receipts/ocr-noise-in-digits.json | 122 ++++ SavelyTests/Fixtures/Receipts/oxxo-cash.json | 249 +++++++ .../Fixtures/Receipts/pemex-importe.json | 134 ++++ .../Fixtures/Receipts/percent-and-noise.json | 148 +++++ .../Fixtures/Receipts/restaurant-tip.json | 228 +++++++ .../Receipts/uber-eats-mixed-case.json | 145 ++++ .../Fixtures/Receipts/us-grocery-cash.json | 238 +++++++ .../Fixtures/Receipts/whole-peso-total.json | 113 ++++ SavelyTests/ReceiptFixtureTests.swift | 115 ++++ SavelyTests/ReceiptOCRIntegrationTests.swift | 95 +++ SavelyTests/ReceiptParserTests.swift | 193 ++++++ SavelyUITests/ReceiptScanUITests.swift | 65 ++ 46 files changed, 6452 insertions(+), 206 deletions(-) create mode 100644 Savely/Utilities/Receipt/OCRLine.swift create mode 100644 Savely/Utilities/Receipt/ReceiptAmountGrammar.swift create mode 100644 Savely/Utilities/Receipt/ReceiptDateParser.swift create mode 100644 Savely/Utilities/Receipt/ReceiptParser.swift create mode 100644 Savely/Utilities/Receipt/ReceiptVocabulary.swift create mode 100644 Savely/Utilities/ReceiptOCR.swift create mode 100644 Savely/ViewModels/Camera/ReceiptScanModel.swift create mode 100644 Savely/Views/Components/CameraScanner/DocumentCameraView.swift create mode 100644 Savely/Views/Components/CameraScanner/ReceiptReviewView.swift create mode 100644 Savely/Views/Components/CameraScanner/ReceiptScanFlowView.swift create mode 100644 SavelyTests/Fixtures/Images/README.md create mode 100644 SavelyTests/Fixtures/Images/synthetic-oxxo.png create mode 100644 SavelyTests/Fixtures/Receipts/README.md create mode 100644 SavelyTests/Fixtures/Receipts/coffee-under-ten.json create mode 100644 SavelyTests/Fixtures/Receipts/column-split.json create mode 100644 SavelyTests/Fixtures/Receipts/comma-decimal.json create mode 100644 SavelyTests/Fixtures/Receipts/costco-no-separators.json create mode 100644 SavelyTests/Fixtures/Receipts/dual-currency-mx-locale.json create mode 100644 SavelyTests/Fixtures/Receipts/dual-currency-us-locale.json create mode 100644 SavelyTests/Fixtures/Receipts/label-above-amount.json create mode 100644 SavelyTests/Fixtures/Receipts/no-total-label.json create mode 100644 SavelyTests/Fixtures/Receipts/ocr-noise-in-digits.json create mode 100644 SavelyTests/Fixtures/Receipts/oxxo-cash.json create mode 100644 SavelyTests/Fixtures/Receipts/pemex-importe.json create mode 100644 SavelyTests/Fixtures/Receipts/percent-and-noise.json create mode 100644 SavelyTests/Fixtures/Receipts/restaurant-tip.json create mode 100644 SavelyTests/Fixtures/Receipts/uber-eats-mixed-case.json create mode 100644 SavelyTests/Fixtures/Receipts/us-grocery-cash.json create mode 100644 SavelyTests/Fixtures/Receipts/whole-peso-total.json create mode 100644 SavelyTests/ReceiptFixtureTests.swift create mode 100644 SavelyTests/ReceiptOCRIntegrationTests.swift create mode 100644 SavelyTests/ReceiptParserTests.swift create mode 100644 SavelyUITests/ReceiptScanUITests.swift diff --git a/.claude/knowledge/gotchas.yaml b/.claude/knowledge/gotchas.yaml index 865f3c9..e72785a 100644 --- a/.claude/knowledge/gotchas.yaml +++ b/.claude/knowledge/gotchas.yaml @@ -52,3 +52,8 @@ summary: Text sizes go through .warmFont(size, weight:, design:) — never .font(.system(size:)) — and Toggle/icon-only Buttons carry a real label. why: The design is specified in fixed point sizes; .system(size:) ignores Dynamic Type entirely, which broke the WCAG AA commitment (PRODUCT.md). .warmFont renders the exact design size at the default content size (unit-tested identity) and scales on the matching text-style curve; it is a ViewModifier that reads the size category so views re-render on change. Two Text concatenations in AddGoalFlow use @ScaledMetric because they need a Font value. VoiceOver read "Switch, on" and "Button" for unlabeled controls before PR D/D3. how-to-apply: New text → .warmFont(...). Fixed widths around text → @ScaledMetric. Small controls → .tappable44(). Icon-only Button → .accessibilityLabel(...). Decorative Image/tile → .accessibilityHidden(true). Rows → .accessibilityElement(children: .combine) with a value. Presented roots (sheet/fullScreenCover) → .honorsReduceMotion(). Bars that cannot carry AX text (tab bar, keypad) cap with .dynamicTypeSize(...DynamicTypeSize.xxxLarge) like UIKit does. Spoken strings: literals or Text/String(localized:) so they land in the catalog with es-419. + +- id: receipt-scanner-on-device-only + summary: The receipt scanner is Vision + a pure ReceiptParser; it never calls the network and its rules only change together with a fixture in SavelyTests/Fixtures/Receipts. + why: PRODUCT.md promises "money data lives on the phone and never leaves it" and onboarding says so. The 2026-08 spike (docs/spikes/receipt-scan-spike.md) found the old scanner recorded EFECTIVO instead of TOTAL on Mexican receipts, dropped leading digits ("1234.56" -> 234.56), rejected totals under 10, and lost the image orientation before OCR — all untested. The rewrite made extraction deterministic and fixture-driven so regressions fail CI without a camera. + how-to-apply: OCR config lives in Utilities/ReceiptOCR.swift (always pass CGImagePropertyOrientation; recognitionLanguages resolved at runtime to es+en). Extraction lives in Utilities/Receipt/ (vocabulary, amount grammar, dates, parser); to change a rule, add or edit a JSON fixture first (hand-authored fixtures say so in "source"; device dumps must be anonymized — no RFC/phone/card/name lines, no images). The DEBUG-only "Export OCR dump" button on the review screen writes a fixture-shaped JSON. FeatureFlags.useSystemDocumentCamera swaps the capture surface for VNDocumentCameraViewController; the Simulator supports neither camera, so use "Choose from Photos" there. diff --git a/CLAUDE.md b/CLAUDE.md index af0cf6d..55fde84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ Savely/ ├── Views/ # SwiftUI screens; ContentView.swift here owns AppState routing ├── Managers/ # Singletons that touch the outside world ├── Extensions/ # Extension.swift — split implementations -├── Utilities/ # OpenAIClient, OCR, helpers +├── Utilities/ # OpenAIClient, ReceiptOCR + Receipt/ (parser), helpers ├── Resources/ # Strings.swift, Localizable.xcstrings, Color+Warm.swift, UIConstants.swift ├── Assets.xcassets/ # Images + ColorPalette ├── Savely.entitlements # Sign in with Apple @@ -96,7 +96,8 @@ These are invariants that future changes must respect. They were discovered duri 4. **iOS 26.0 is the minimum deployment target** for the app target. Use modern APIs freely (`@Observable`, `NavigationStack`, Swift Testing, etc.). 5. **Only `Savely.xcscheme` is shared and tracked** in `Savely.xcodeproj/xcshareddata/xcschemes/`. Test commands must use `-scheme Savely`, never `-scheme SavelyTests`. 6. **Localization rule:** all user-facing strings go through `Resources/Strings.swift` constants and are registered in `Localizable.xcstrings`. No hardcoded literals in views. -7. **Singletons own external I/O:** `AuthenticationManager.shared`, `UserManager.shared`, `NotificationManager.shared`, `CameraManager.shared`, `OpenAIClient.shared`. Manager bodies are split across `Managers/.swift` and `Extensions/Extension.swift`. +7. **Managers own external I/O:** `NotificationManager.shared`, `OpenAIClient.shared` are singletons; `CameraManager` is one instance per scan session, owned by `ReceiptScanModel` (no `.shared` — the old singleton went with the 2026-08 scanner rewrite). Manager bodies are split across `Managers/.swift` and `Extensions/Extension.swift`. +10. **Receipt scanning stays on-device.** Capture → `ReceiptOCR` (Vision, orientation + es/en languages) → `ReceiptParser` (pure, fixture-tested in `SavelyTests/Fixtures/Receipts`) → review screen. No network, no cloud OCR, no image persisted. Rationale and the option survey: `docs/spikes/receipt-scan-spike.md`. Extraction rules change ONLY with a fixture that proves them; fixtures are anonymized JSON OCR dumps, never images. 8. **`main` and `dev` are protected.** PRs only, CI must be green, no force-push. No auto-merge — the developer merges manually. 9. **AI tips are feature-flagged OFF for the App Store release** (`FeatureFlags.tipsEnabled`, `Savely/Utilities/FeatureFlags.swift`). Every tips surface checks the flag; tips code is not deleted — the flag is the off switch. See `gotchas.yaml#tips-behind-feature-flag`. 9. **Solo workflow:** one PR at a time. For non-trivial calls (architecture, dependencies, new patterns), present options with trade-offs instead of deciding silently. diff --git a/Savely.xcodeproj/project.pbxproj b/Savely.xcodeproj/project.pbxproj index 07720d9..c98d9f2 100644 --- a/Savely.xcodeproj/project.pbxproj +++ b/Savely.xcodeproj/project.pbxproj @@ -7,6 +7,21 @@ objects = { /* Begin PBXBuildFile section */ + C5DDD46B40453371BD90C01A /* ReceiptScanUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */; }; + 7479BDFD4C96ED1C9E05A119 /* ReceiptOCRIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */; }; + 31A69355DA4D561A928D09D7 /* DocumentCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DA345E04E3ACA1DEDFCDD2 /* DocumentCameraView.swift */; }; + 15C9F87752F242860A062A9C /* ReceiptScanFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A17E22922859025B4DEE1D /* ReceiptScanFlowView.swift */; }; + 6BEBD87A257D196FC2B5F572 /* ReceiptReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA5F5E26408477F8E6C59BC /* ReceiptReviewView.swift */; }; + 5A923D851E1F0A9DE0182E2A /* ReceiptScanModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 410C0D1F40524DF8CFE25021 /* ReceiptScanModel.swift */; }; + CEBCEA30F32B686EB71A2866 /* ReceiptOCR.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C90599A224DF05C1C7BC95 /* ReceiptOCR.swift */; }; + 46C940AC7FC071D52F7037C9 /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 9FDC7BEFB3FA01CDB951DBBA /* Fixtures */; }; + EB84DBEF8B09F9EB1E7AAFF6 /* ReceiptFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C6F50EC75E1A4FF9BE4992A /* ReceiptFixtureTests.swift */; }; + 9AB16553AE439FAB12574217 /* ReceiptParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10A2405B0A3780703DFC27C9 /* ReceiptParserTests.swift */; }; + 098D870F358FFD061FE47F1A /* ReceiptParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740B73ADB53C98937307DBAA /* ReceiptParser.swift */; }; + 5522F0A81CBF724D40756A20 /* ReceiptDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B90A9EEB228FE2A1132DFD /* ReceiptDateParser.swift */; }; + 5C26C81FFC3D8C3867044BB4 /* ReceiptAmountGrammar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505D129592F5E1D668ECF4C3 /* ReceiptAmountGrammar.swift */; }; + 6A21AE5F9EDE58BDDB5A1514 /* ReceiptVocabulary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA22FF0B24D8017909BA914 /* ReceiptVocabulary.swift */; }; + 21A73EF62C20FDA050059206 /* OCRLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFA2F094D50821E2DA0497A4 /* OCRLine.swift */; }; AA28B8C8B38F312A7623F2D2 /* AutoMoveSuggestionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AFC2151C01A7FBD2947EB51 /* AutoMoveSuggestionTests.swift */; }; DE826483AC71FF3EC2A603B7 /* AutoMoveSuggestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = F774124079B5BEC3DD37C743 /* AutoMoveSuggestion.swift */; }; A11A0002C0DE5AFE10000002 /* AmountParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0001C0DE5AFE10000001 /* AmountParsing.swift */; }; @@ -37,11 +52,9 @@ 1D88B7CF2CC6EB62009B6538 /* CameraManagerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D88B7CE2CC6EB62009B6538 /* CameraManagerExtension.swift */; }; 1D88B7D12CC6EBB4009B6538 /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D88B7D02CC6EBB4009B6538 /* CameraView.swift */; }; 1D88B7D32CC9F70C009B6538 /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D88B7D22CC9F70C009B6538 /* CameraPreview.swift */; }; - 1D88B7D82CC9FA34009B6538 /* OCRUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D88B7D72CC9FA34009B6538 /* OCRUtilities.swift */; }; 1DAF5D092CED9CA3001C0AF3 /* IncomeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D082CED9CA3001C0AF3 /* IncomeModel.swift */; }; 1DAF5D0C2CEDA6C0001C0AF3 /* IncomesTrackerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D0B2CEDA6C0001C0AF3 /* IncomesTrackerViewModel.swift */; }; 1DAF5D132CEDB421001C0AF3 /* ExpensesTrackerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D122CEDB421001C0AF3 /* ExpensesTrackerViewModel.swift */; }; - 1DAF5D162CEDB69B001C0AF3 /* CameraViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D152CEDB69B001C0AF3 /* CameraViewModel.swift */; }; 1DAF5D1B2CEDC118001C0AF3 /* GoalsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D1A2CEDC118001C0AF3 /* GoalsViewModel.swift */; }; 1DAF5D1E2CEE9695001C0AF3 /* TipsAndSuggestionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D1D2CEE9695001C0AF3 /* TipsAndSuggestionViewModel.swift */; }; 1DAF5D202CEE9767001C0AF3 /* TipHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D1F2CEE9767001C0AF3 /* TipHistoryView.swift */; }; @@ -87,8 +100,6 @@ 1DF722922F9C19CE00320B38 /* MoneyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF722912F9C19CE00320B38 /* MoneyView.swift */; }; 1DF722942F9C19E600320B38 /* AchievementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF722932F9C19E600320B38 /* AchievementsView.swift */; }; 1DF722962F9C2FA400320B38 /* AddGoalFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF722952F9C2FA400320B38 /* AddGoalFlow.swift */; }; - 82D33CE22CEC66D200BE005E /* TextRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D33CE12CEC66D200BE005E /* TextRecognizer.swift */; }; - 82D33CE42CEC66F200BE005E /* TotalConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D33CE32CEC66F200BE005E /* TotalConfirmationView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -109,6 +120,21 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanUITests.swift; sourceTree = ""; }; + 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptOCRIntegrationTests.swift; sourceTree = ""; }; + D2DA345E04E3ACA1DEDFCDD2 /* DocumentCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentCameraView.swift; sourceTree = ""; }; + 06A17E22922859025B4DEE1D /* ReceiptScanFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanFlowView.swift; sourceTree = ""; }; + 0CA5F5E26408477F8E6C59BC /* ReceiptReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptReviewView.swift; sourceTree = ""; }; + 410C0D1F40524DF8CFE25021 /* ReceiptScanModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanModel.swift; sourceTree = ""; }; + D2C90599A224DF05C1C7BC95 /* ReceiptOCR.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptOCR.swift; sourceTree = ""; }; + 9FDC7BEFB3FA01CDB951DBBA /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; + 9C6F50EC75E1A4FF9BE4992A /* ReceiptFixtureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptFixtureTests.swift; sourceTree = ""; }; + 10A2405B0A3780703DFC27C9 /* ReceiptParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptParserTests.swift; sourceTree = ""; }; + 740B73ADB53C98937307DBAA /* ReceiptParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptParser.swift; sourceTree = ""; }; + 20B90A9EEB228FE2A1132DFD /* ReceiptDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptDateParser.swift; sourceTree = ""; }; + 505D129592F5E1D668ECF4C3 /* ReceiptAmountGrammar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptAmountGrammar.swift; sourceTree = ""; }; + AEA22FF0B24D8017909BA914 /* ReceiptVocabulary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptVocabulary.swift; sourceTree = ""; }; + CFA2F094D50821E2DA0497A4 /* OCRLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRLine.swift; sourceTree = ""; }; 9AFC2151C01A7FBD2947EB51 /* AutoMoveSuggestionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoMoveSuggestionTests.swift; sourceTree = ""; }; F774124079B5BEC3DD37C743 /* AutoMoveSuggestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoMoveSuggestion.swift; sourceTree = ""; }; A11A0001C0DE5AFE10000001 /* AmountParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountParsing.swift; sourceTree = ""; }; @@ -139,11 +165,9 @@ 1D88B7CE2CC6EB62009B6538 /* CameraManagerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraManagerExtension.swift; sourceTree = ""; }; 1D88B7D02CC6EBB4009B6538 /* CameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = ""; }; 1D88B7D22CC9F70C009B6538 /* CameraPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = ""; }; - 1D88B7D72CC9FA34009B6538 /* OCRUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRUtilities.swift; sourceTree = ""; }; 1DAF5D082CED9CA3001C0AF3 /* IncomeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomeModel.swift; sourceTree = ""; }; 1DAF5D0B2CEDA6C0001C0AF3 /* IncomesTrackerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomesTrackerViewModel.swift; sourceTree = ""; }; 1DAF5D122CEDB421001C0AF3 /* ExpensesTrackerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpensesTrackerViewModel.swift; sourceTree = ""; }; - 1DAF5D152CEDB69B001C0AF3 /* CameraViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraViewModel.swift; sourceTree = ""; }; 1DAF5D1A2CEDC118001C0AF3 /* GoalsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalsViewModel.swift; sourceTree = ""; }; 1DAF5D1D2CEE9695001C0AF3 /* TipsAndSuggestionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipsAndSuggestionViewModel.swift; sourceTree = ""; }; 1DAF5D1F2CEE9767001C0AF3 /* TipHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipHistoryView.swift; sourceTree = ""; }; @@ -194,8 +218,6 @@ 1DF722912F9C19CE00320B38 /* MoneyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyView.swift; sourceTree = ""; }; 1DF722932F9C19E600320B38 /* AchievementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementsView.swift; sourceTree = ""; }; 1DF722952F9C2FA400320B38 /* AddGoalFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGoalFlow.swift; sourceTree = ""; }; - 82D33CE12CEC66D200BE005E /* TextRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRecognizer.swift; sourceTree = ""; }; - 82D33CE32CEC66F200BE005E /* TotalConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TotalConfirmationView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -223,6 +245,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5A440101E954A364F752379E /* Receipt */ = { + isa = PBXGroup; + children = ( + 740B73ADB53C98937307DBAA /* ReceiptParser.swift */, + 20B90A9EEB228FE2A1132DFD /* ReceiptDateParser.swift */, + 505D129592F5E1D668ECF4C3 /* ReceiptAmountGrammar.swift */, + AEA22FF0B24D8017909BA914 /* ReceiptVocabulary.swift */, + CFA2F094D50821E2DA0497A4 /* OCRLine.swift */, + ); + path = Receipt; + sourceTree = ""; + }; 1D3669842CC62EDD00E84FF9 /* ViewModels */ = { isa = PBXGroup; children = ( @@ -270,6 +304,8 @@ 1D88B7D62CC9FA28009B6538 /* Utilities */ = { isa = PBXGroup; children = ( + D2C90599A224DF05C1C7BC95 /* ReceiptOCR.swift */, + 5A440101E954A364F752379E /* Receipt */, F774124079B5BEC3DD37C743 /* AutoMoveSuggestion.swift */, A11A0001C0DE5AFE10000001 /* AmountParsing.swift */, A11A0005C0DE5AFE10000005 /* ReminderSettings.swift */, @@ -281,8 +317,6 @@ 12789AD27FF1A7114156F945 /* AchievementEngine.swift */, 693B815A380D566280140319 /* FeatureFlags.swift */, 1DE9FE162D066EE20069AA64 /* ReportsPDFGenerator.swift */, - 82D33CE12CEC66D200BE005E /* TextRecognizer.swift */, - 1D88B7D72CC9FA34009B6538 /* OCRUtilities.swift */, 1DE4B4CF2CE5F2D6006C8F65 /* OnboardingData.swift */, 1DAF5D252CEE9C13001C0AF3 /* OpenAIClient.swift */, 1DAF5D322CEEE7AA001C0AF3 /* Notifications.swift */, @@ -293,7 +327,9 @@ 1DAF5D022CED9481001C0AF3 /* CameraScanner */ = { isa = PBXGroup; children = ( - 82D33CE32CEC66F200BE005E /* TotalConfirmationView.swift */, + D2DA345E04E3ACA1DEDFCDD2 /* DocumentCameraView.swift */, + 06A17E22922859025B4DEE1D /* ReceiptScanFlowView.swift */, + 0CA5F5E26408477F8E6C59BC /* ReceiptReviewView.swift */, 1D88B7D22CC9F70C009B6538 /* CameraPreview.swift */, 1D88B7D02CC6EBB4009B6538 /* CameraView.swift */, ); @@ -342,7 +378,7 @@ 1DAF5D142CEDB690001C0AF3 /* Camera */ = { isa = PBXGroup; children = ( - 1DAF5D152CEDB69B001C0AF3 /* CameraViewModel.swift */, + 410C0D1F40524DF8CFE25021 /* ReceiptScanModel.swift */, ); path = Camera; sourceTree = ""; @@ -473,6 +509,10 @@ 1DC258172CC07DCF0002DDE3 /* SavelyTests */ = { isa = PBXGroup; children = ( + 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */, + 9FDC7BEFB3FA01CDB951DBBA /* Fixtures */, + 9C6F50EC75E1A4FF9BE4992A /* ReceiptFixtureTests.swift */, + 10A2405B0A3780703DFC27C9 /* ReceiptParserTests.swift */, 9AFC2151C01A7FBD2947EB51 /* AutoMoveSuggestionTests.swift */, A11A0003C0DE5AFE10000003 /* AmountParsingTests.swift */, A11A0007C0DE5AFE10000007 /* ReminderSettingsTests.swift */, @@ -490,6 +530,7 @@ 1DC258212CC07DCF0002DDE3 /* SavelyUITests */ = { isa = PBXGroup; children = ( + C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */, 1DC258222CC07DCF0002DDE3 /* SavelyUITests.swift */, 1DC258242CC07DCF0002DDE3 /* SavelyUITestsLaunchTests.swift */, ); @@ -677,6 +718,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 46C940AC7FC071D52F7037C9 /* Fixtures in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -694,6 +736,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 31A69355DA4D561A928D09D7 /* DocumentCameraView.swift in Sources */, + 15C9F87752F242860A062A9C /* ReceiptScanFlowView.swift in Sources */, + 6BEBD87A257D196FC2B5F572 /* ReceiptReviewView.swift in Sources */, + 5A923D851E1F0A9DE0182E2A /* ReceiptScanModel.swift in Sources */, + CEBCEA30F32B686EB71A2866 /* ReceiptOCR.swift in Sources */, + 098D870F358FFD061FE47F1A /* ReceiptParser.swift in Sources */, + 5522F0A81CBF724D40756A20 /* ReceiptDateParser.swift in Sources */, + 5C26C81FFC3D8C3867044BB4 /* ReceiptAmountGrammar.swift in Sources */, + 6A21AE5F9EDE58BDDB5A1514 /* ReceiptVocabulary.swift in Sources */, + 21A73EF62C20FDA050059206 /* OCRLine.swift in Sources */, 1DC2584B2CC629540002DDE3 /* ExpenseModel.swift in Sources */, 1D88B7CC2CC6EA47009B6538 /* CameraManager.swift in Sources */, 1DF722942F9C19E600320B38 /* AchievementsView.swift in Sources */, @@ -719,12 +771,10 @@ ED04D2F84390584EB7EC0615 /* WelcomeStepView.swift in Sources */, 699FCB7F699B1E9C9C644AF3 /* SproutMark.swift in Sources */, 8DA6B54FCD7F5EA515DBC1CC /* FeatureFlags.swift in Sources */, - 82D33CE22CEC66D200BE005E /* TextRecognizer.swift in Sources */, 1DE9FE102D063BC80069AA64 /* NotificationManager.swift in Sources */, 1DF722902F9C18FE00320B38 /* GoalDetailView.swift in Sources */, 1D88B7D32CC9F70C009B6538 /* CameraPreview.swift in Sources */, 1DE4B4EC2CEC4EE3006C8F65 /* AppViewModel.swift in Sources */, - 1DAF5D162CEDB69B001C0AF3 /* CameraViewModel.swift in Sources */, 1DC0140D2CE5AF59009A46E1 /* OnboardingView.swift in Sources */, 1DAF5D332CEEE7AA001C0AF3 /* Notifications.swift in Sources */, 1DC258322CC07DF40002DDE3 /* DashboardView.swift in Sources */, @@ -743,7 +793,6 @@ 1DC258082CC07DCC0002DDE3 /* SavelyApp.swift in Sources */, 1DF722962F9C2FA400320B38 /* AddGoalFlow.swift in Sources */, 1DAF5D202CEE9767001C0AF3 /* TipHistoryView.swift in Sources */, - 82D33CE42CEC66F200BE005E /* TotalConfirmationView.swift in Sources */, 1DE9FE172D066EE20069AA64 /* ReportsPDFGenerator.swift in Sources */, 1D3669892CC62F9100E84FF9 /* UIConstants.swift in Sources */, 1DAF5D0C2CEDA6C0001C0AF3 /* IncomesTrackerViewModel.swift in Sources */, @@ -752,7 +801,6 @@ 1DEACCDE2D0BE6340084CC39 /* NetworkErrorView.swift in Sources */, 1DAF5D1B2CEDC118001C0AF3 /* GoalsViewModel.swift in Sources */, 1DC014112CE5AFAE009A46E1 /* OnboardingStepModel.swift in Sources */, - 1D88B7D82CC9FA34009B6538 /* OCRUtilities.swift in Sources */, 1DE9FE152D0664A60069AA64 /* ReportsViewModel.swift in Sources */, 1DC0140F2CE5AF81009A46E1 /* OnboardingStepView.swift in Sources */, 1DAF5D262CEE9C13001C0AF3 /* OpenAIClient.swift in Sources */, @@ -766,6 +814,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7479BDFD4C96ED1C9E05A119 /* ReceiptOCRIntegrationTests.swift in Sources */, + EB84DBEF8B09F9EB1E7AAFF6 /* ReceiptFixtureTests.swift in Sources */, + 9AB16553AE439FAB12574217 /* ReceiptParserTests.swift in Sources */, 1DC258192CC07DCF0002DDE3 /* SavelyTests.swift in Sources */, AA28B8C8B38F312A7623F2D2 /* AutoMoveSuggestionTests.swift in Sources */, A11A0004C0DE5AFE10000004 /* AmountParsingTests.swift in Sources */, @@ -783,6 +834,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C5DDD46B40453371BD90C01A /* ReceiptScanUITests.swift in Sources */, 1DC258252CC07DCF0002DDE3 /* SavelyUITestsLaunchTests.swift in Sources */, 1DC258232CC07DCF0002DDE3 /* SavelyUITests.swift in Sources */, ); diff --git a/Savely/Extensions/CameraManagerExtension.swift b/Savely/Extensions/CameraManagerExtension.swift index ee6e15d..67b4f7c 100755 --- a/Savely/Extensions/CameraManagerExtension.swift +++ b/Savely/Extensions/CameraManagerExtension.swift @@ -6,55 +6,31 @@ // import AVFoundation -import Vision // MARK: - AVCapturePhotoCaptureDelegate extension CameraManager: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { - if let data = photo.fileDataRepresentation() { - DispatchQueue.main.async { - self.photoData = data - } + if let error { + finishCapture(with: .failure(error)) + return } - } -} - -// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate -extension CameraManager: AVCaptureVideoDataOutputSampleBufferDelegate { - func captureOutput(_ output: AVCaptureOutput, - didOutput sampleBuffer: CMSampleBuffer, - from connection: AVCaptureConnection) { - guard !isProcessingFrame else { return } - isProcessingFrame = true - - guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { - isProcessingFrame = false + guard let data = photo.fileDataRepresentation() else { + finishCapture(with: .failure(CameraError.captureFailed)) return } + finishCapture(with: .success(data)) + } - let requestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .up, options: [:]) - - DispatchQueue.global(qos: .userInitiated).async { - do { - try requestHandler.perform([self.rectangleDetectionRequest]) - if let results = self.rectangleDetectionRequest.results as? [VNRectangleObservation], let rectangle = results.first { - DispatchQueue.main.async { - self.isRectangleDetected = true - self.detectedRectangle = rectangle - } - } else { - DispatchQueue.main.async { - self.isRectangleDetected = false - self.detectedRectangle = nil - } - } - } catch { - print("Error al realizar la solicitud de detección de rectángulos: \(error)") - } - - self.isProcessingFrame = false + /// Fires even when `didFinishProcessingPhoto` never does (rare capture + /// pipeline errors); `finishCapture` ignores a second resume, so this + /// only matters when the continuation is still pending. + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, + error: Error?) { + if let error { + finishCapture(with: .failure(error)) } } } diff --git a/Savely/Managers/CameraManager.swift b/Savely/Managers/CameraManager.swift index eeccbcc..f80e64c 100755 --- a/Savely/Managers/CameraManager.swift +++ b/Savely/Managers/CameraManager.swift @@ -4,38 +4,51 @@ // // Created by Ivan Lorenzana Belli on 21/10/24. // +// Owns the AVCaptureSession behind the receipt scanner: permission, +// configuration, the preview layer, torch, and a single still capture. +// The still is delivered with its EXIF orientation set from an +// `AVCaptureDevice.RotationCoordinator`, so OCR sees the receipt the way the +// user held the phone (the old code never set the connection's rotation and +// then dropped the orientation again before Vision — see docs/spikes). +// import AVFoundation -import Vision import UIKit +import Observation + +enum CameraError: Error, Equatable { + /// The session is not configured/running (no permission, no camera, + /// or the configuration is still in flight). + case notReady + /// A capture is already in progress. + case busy + /// AVFoundation reported a capture failure or produced no data. + case captureFailed +} -class CameraManager: NSObject, ObservableObject { - @Published var photoData: Data? - @Published var isRectangleDetected: Bool = false - @Published var detectedRectangle: VNRectangleObservation? - - private let session = AVCaptureSession() - private var photoOutput = AVCapturePhotoOutput() - private var videoOutput = AVCaptureVideoDataOutput() - private let sessionQueue = DispatchQueue(label: "session_queue") - private var previewLayer: AVCaptureVideoPreviewLayer? - - let rectangleDetectionRequest: VNDetectRectanglesRequest = { - let request = VNDetectRectanglesRequest() - request.maximumObservations = 1 - request.minimumConfidence = 0.8 - request.minimumAspectRatio = 0.3 - request.maximumAspectRatio = 1.0 - return request - }() - - var isProcessingFrame = false - +@Observable +final class CameraManager: NSObject { /// False on devices without a back camera (the simulator) or when the /// user denied camera access — the view shows a message instead of a /// frozen black preview. - @Published var isCameraAvailable = true - private var isConfigured = false + private(set) var isCameraAvailable = true + /// True once the session streams frames — the shutter is enabled only then. + private(set) var isRunning = false + private(set) var isTorchOn = false + private(set) var hasTorch = false + + @ObservationIgnored let session = AVCaptureSession() + @ObservationIgnored private let photoOutput = AVCapturePhotoOutput() + @ObservationIgnored private let sessionQueue = DispatchQueue(label: "savely.camera.session") + @ObservationIgnored private var videoDevice: AVCaptureDevice? + @ObservationIgnored private var previewLayer: AVCaptureVideoPreviewLayer? + @ObservationIgnored private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? + @ObservationIgnored private var rotationObservation: NSKeyValueObservation? + @ObservationIgnored private var isConfigured = false + /// The pending `capturePhoto()` caller. Guarded by `sessionQueue`. + @ObservationIgnored var photoContinuation: CheckedContinuation? + + // MARK: - Session lifecycle func startSession() { AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in @@ -47,13 +60,23 @@ class CameraManager: NSObject, ObservableObject { self.sessionQueue.async { guard self.configureSessionIfNeeded() else { return } if !self.session.isRunning { self.session.startRunning() } + let running = self.session.isRunning + DispatchQueue.main.async { + // Coming back from Settings after granting access lands here + // with `isCameraAvailable == false` from the earlier denial. + self.isCameraAvailable = true + self.isRunning = running + self.installRotationCoordinatorIfPossible() + } } } } func stopSession() { + setTorch(false) sessionQueue.async { if self.session.isRunning { self.session.stopRunning() } + DispatchQueue.main.async { self.isRunning = false } } } @@ -67,53 +90,52 @@ class CameraManager: NSObject, ObservableObject { defer { session.commitConfiguration() } session.sessionPreset = .photo - guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { - print("No back camera on this device.") - DispatchQueue.main.async { self.isCameraAvailable = false } + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { + markUnavailable("No back camera on this device.") return false } do { - let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice) - guard session.canAddInput(videoDeviceInput) else { - print("Could not add the camera input.") - DispatchQueue.main.async { self.isCameraAvailable = false } + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { + markUnavailable("Could not add the camera input.") return false } - session.addInput(videoDeviceInput) + session.addInput(input) } catch { - print("Could not create the camera input: \(error)") - DispatchQueue.main.async { self.isCameraAvailable = false } + markUnavailable("Could not create the camera input: \(error)") return false } guard session.canAddOutput(photoOutput) else { - print("Could not add the photo output.") - DispatchQueue.main.async { self.isCameraAvailable = false } + markUnavailable("Could not add the photo output.") return false } session.addOutput(photoOutput) + // Speed over maximum quality: a receipt is text at close range and + // the user is waiting for the result. + photoOutput.maxPhotoQualityPrioritization = .balanced - videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "video_output_queue")) - guard session.canAddOutput(videoOutput) else { - print("Could not add the video output.") - DispatchQueue.main.async { self.isCameraAvailable = false } - return false - } - session.addOutput(videoOutput) - videoOutput.alwaysDiscardsLateVideoFrames = true - videoOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] - + videoDevice = device isConfigured = true + DispatchQueue.main.async { self.hasTorch = device.hasTorch } return true } + private func markUnavailable(_ reason: String) { + print(reason) + DispatchQueue.main.async { self.isCameraAvailable = false } + } + + // MARK: - Preview + func setPreviewLayer(to view: UIView) { let layer = AVCaptureVideoPreviewLayer(session: session) layer.videoGravity = .resizeAspectFill layer.frame = view.bounds previewLayer = layer view.layer.insertSublayer(layer, at: 0) + installRotationCoordinatorIfPossible() } /// Keeps the preview filling the view when it is laid out (sheet vs @@ -122,8 +144,79 @@ class CameraManager: NSObject, ObservableObject { previewLayer?.frame = view.bounds } - func capturePhoto() { - let settings = AVCapturePhotoSettings() - photoOutput.capturePhoto(with: settings, delegate: self) + /// Once both the device and the preview layer exist, the coordinator + /// keeps the preview upright and tells us the angle to stamp on captures. + /// Main thread only (KVO on a UI-facing object). + private func installRotationCoordinatorIfPossible() { + guard rotationCoordinator == nil, let device = videoDevice, let previewLayer else { return } + let coordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: previewLayer) + rotationCoordinator = coordinator + applyPreviewRotation(coordinator.videoRotationAngleForHorizonLevelPreview) + rotationObservation = coordinator.observe(\.videoRotationAngleForHorizonLevelPreview, options: [.new]) { [weak self] coordinator, _ in + self?.applyPreviewRotation(coordinator.videoRotationAngleForHorizonLevelPreview) + } + } + + private func applyPreviewRotation(_ angle: CGFloat) { + guard let connection = previewLayer?.connection, connection.isVideoRotationAngleSupported(angle) else { return } + connection.videoRotationAngle = angle + } + + // MARK: - Torch + + func toggleTorch() { + setTorch(!isTorchOn) + } + + func setTorch(_ on: Bool) { + guard let device = videoDevice, device.hasTorch else { return } + sessionQueue.async { + do { + try device.lockForConfiguration() + device.torchMode = on ? .on : .off + device.unlockForConfiguration() + DispatchQueue.main.async { self.isTorchOn = on } + } catch { + print("Torch unavailable: \(error)") + } + } + } + + // MARK: - Capture + + /// Takes one still and returns its file data (JPEG/HEIF with EXIF + /// orientation). Throws `.notReady` instead of crashing when the session + /// never got configured, and `.busy` while a capture is in flight. + func capturePhoto() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + sessionQueue.async { + guard self.isConfigured, self.session.isRunning, + let connection = self.photoOutput.connection(with: .video), connection.isActive else { + continuation.resume(throwing: CameraError.notReady) + return + } + guard self.photoContinuation == nil else { + continuation.resume(throwing: CameraError.busy) + return + } + if let coordinator = self.rotationCoordinator { + let angle = coordinator.videoRotationAngleForHorizonLevelCapture + if connection.isVideoRotationAngleSupported(angle) { connection.videoRotationAngle = angle } + } + let settings = AVCapturePhotoSettings() + settings.photoQualityPrioritization = .balanced + self.photoContinuation = continuation + self.photoOutput.capturePhoto(with: settings, delegate: self) + } + } + } + + /// Called by the capture delegate (see CameraManagerExtension.swift). + func finishCapture(with result: Result) { + sessionQueue.async { + guard let continuation = self.photoContinuation else { return } + self.photoContinuation = nil + continuation.resume(with: result) + } } } diff --git a/Savely/Models/ExpenseModel.swift b/Savely/Models/ExpenseModel.swift index 7f59268..f4a687c 100755 --- a/Savely/Models/ExpenseModel.swift +++ b/Savely/Models/ExpenseModel.swift @@ -15,9 +15,10 @@ class ExpenseModel { var amount: Double var date: Date /// The chip the user picked when logging ("Coffee", "Food", "Transit", - /// "Shopping", "Other"). `nil` for rows logged before categories were - /// stored and for scanned receipts — display then falls back to keyword - /// inference, but the guess is never written back here. + /// "Shopping", "Other"). Scanned receipts store the chip confirmed on the + /// review screen. `nil` only for rows logged before categories were + /// stored — display then falls back to keyword inference, but the guess + /// is never written back here. var category: String? init(expenseDescription: String, amount: Double, date: Date, category: String? = nil) { diff --git a/Savely/Resources/Localizable.xcstrings b/Savely/Resources/Localizable.xcstrings index b522385..8b12251 100755 --- a/Savely/Resources/Localizable.xcstrings +++ b/Savely/Resources/Localizable.xcstrings @@ -9,6 +9,634 @@ }, " of %lld" : { + }, + "error_invalid_amount" : { + "comment" : "Shown when an amount is empty, not a number, or not positive", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enter a valid amount greater than zero." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escribe un monto válido mayor que cero." + } + } + } + }, + "error_save_expense_failed" : { + "comment" : "Shown when writing an expense to the local store fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We couldn't save the expense. Please try again." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "No pudimos guardar el gasto. Inténtalo de nuevo." + } + } + } + }, + "scan_alternatives_label" : { + "comment" : "Label above the alternative amount chips", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Also on the receipt" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "También en el recibo" + } + } + } + }, + "scan_amount_accessibility_label" : { + "comment" : "Accessibility label of the amount on the review screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Expense amount" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monto del gasto" + } + } + } + }, + "scan_camera_starting_body" : { + "comment" : "Shown when the shutter was tapped before the camera was ready", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The camera is still starting — try again in a moment." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "La cámara aún está iniciando — inténtalo de nuevo en un momento." + } + } + } + }, + "scan_camera_unavailable_body" : { + "comment" : "Body when the camera cannot start", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow camera access in Settings, or choose a receipt photo instead." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permite el acceso a la cámara en Ajustes, o elige una foto del recibo." + } + } + } + }, + "scan_camera_unavailable_title" : { + "comment" : "Shown when the camera cannot start", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Camera not available" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cámara no disponible" + } + } + } + }, + "scan_close_label" : { + "comment" : "Accessibility label of the close button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar" + } + } + } + }, + "scan_date_from_receipt_note" : { + "comment" : "Note next to the date when it was read from the receipt", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "from the receipt" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "del recibo" + } + } + } + }, + "scan_date_label" : { + "comment" : "Label of the date picker on the review screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Date" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fecha" + } + } + } + }, + "scan_date_today_note" : { + "comment" : "Note next to the date when it defaulted to today", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "today — change it if the receipt is older" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "hoy — cámbiala si el recibo es de otro día" + } + } + } + }, + "scan_enter_amount_error" : { + "comment" : "Shown when saving with no amount", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enter a valid amount greater than zero." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escribe un monto válido mayor que cero." + } + } + } + }, + "scan_export_dump_label" : { + "comment" : "DEBUG-only button that shares what OCR read as JSON", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Export OCR dump" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar OCR (JSON)" + } + } + } + }, + "scan_guide_hint" : { + "comment" : "Hint under the camera guide frame", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Frame the whole receipt — flat, in good light." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encuadra todo el recibo — plano y con buena luz." + } + } + } + }, + "scan_import_failed_body" : { + "comment" : "Shown when a photo picked from the library could not be loaded", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We couldn't open that photo. Try another one, or take a new picture." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "No pudimos abrir esa foto. Prueba con otra o toma una nueva." + } + } + } + }, + "scan_import_photos_label" : { + "comment" : "Button that opens the photo picker to import a receipt", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Choose from Photos" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elegir de Fotos" + } + } + } + }, + "scan_merchant_placeholder" : { + "comment" : "Placeholder of the merchant field on the review screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Merchant" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comercio" + } + } + } + }, + "scan_open_settings_label" : { + "comment" : "Button that opens the app's Settings page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Settings" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir Ajustes" + } + } + } + }, + "scan_privacy_note" : { + "comment" : "Reassurance shown in the scanner", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Read on your iPhone. The photo never leaves your phone." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se lee en tu iPhone. La foto nunca sale de tu teléfono." + } + } + } + }, + "scan_processing_label" : { + "comment" : "Shown while OCR runs", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reading the receipt…" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leyendo el recibo…" + } + } + } + }, + "scan_read_failed_body" : { + "comment" : "Body when capture or reading fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Try again with the receipt flat and well lit, or enter the amount yourself." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inténtalo de nuevo con el recibo plano y bien iluminado, o escribe el monto tú mismo." + } + } + } + }, + "scan_read_failed_title" : { + "comment" : "Title when capture or reading fails", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We couldn't read this one" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "No pudimos leer este recibo" + } + } + } + }, + "scan_receipt_thumbnail_label" : { + "comment" : "Accessibility label of the receipt thumbnail", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Photo of the receipt" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foto del recibo" + } + } + } + }, + "scan_retake_label" : { + "comment" : "Button that goes back to the camera", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Retake" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repetir" + } + } + } + }, + "scan_retry_label" : { + "comment" : "Retry button after a failed read", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Try again" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intentar de nuevo" + } + } + } + }, + "scan_review_lead_none" : { + "comment" : "Lead copy on the review screen when no amount was found", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We couldn't find a total. Enter it below." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "No encontramos un total. Escríbelo abajo." + } + } + } + }, + "scan_review_lead_read" : { + "comment" : "Lead copy on the review screen when a total was found", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We read this — is it right?" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leímos esto — ¿está bien?" + } + } + } + }, + "scan_review_lead_unsure" : { + "comment" : "Lead copy on the review screen when the total is a low-confidence guess", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We're not sure about the total — please check it." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "No estamos seguros del total — revísalo, por favor." + } + } + } + }, + "scan_review_title" : { + "comment" : "Title of the review screen after a scan", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Check the receipt" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Revisa el recibo" + } + } + } + }, + "scan_save_expense_label" : { + "comment" : "Primary button on the review screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save expense" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar gasto" + } + } + } + }, + "scan_scanned_receipt_description" : { + "comment" : "Description saved when the receipt had no readable merchant", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Scanned receipt" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recibo escaneado" + } + } + } + }, + "scan_shutter_label" : { + "comment" : "Accessibility label of the shutter button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Take photo" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tomar foto" + } + } + } + }, + "scan_torch_off_label" : { + "comment" : "Accessibility label of the torch button when on", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Turn flash off" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagar flash" + } + } + } + }, + "scan_torch_on_label" : { + "comment" : "Accessibility label of the torch button when off", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Turn flash on" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encender flash" + } + } + } + }, + "We'll read the total, merchant and date" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We'll read the total, merchant and date" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leemos el total, el comercio y la fecha" + } + } + } }, "—" : { diff --git a/Savely/Resources/Strings.swift b/Savely/Resources/Strings.swift index a3c525e..e4ed747 100755 --- a/Savely/Resources/Strings.swift +++ b/Savely/Resources/Strings.swift @@ -360,6 +360,14 @@ struct Strings { "error_label", value: "Error", comment: "Error Label") + static let invalidAmount = NSLocalizedString( + "error_invalid_amount", + value: "Enter a valid amount greater than zero.", + comment: "Shown when an amount is empty, not a number, or not positive") + static let saveExpenseFailed = NSLocalizedString( + "error_save_expense_failed", + value: "We couldn't save the expense. Please try again.", + comment: "Shown when writing an expense to the local store fails") } struct Placeholders { @@ -535,6 +543,140 @@ struct Strings { "please_confirm_value_title", value: "Please Confirm Value", comment: "Please Confirm Value Title") + + // Capture + static let guideHint = NSLocalizedString( + "scan_guide_hint", + value: "Frame the whole receipt — flat, in good light.", + comment: "Hint under the camera guide frame") + static let shutterLabel = NSLocalizedString( + "scan_shutter_label", + value: "Take photo", + comment: "Accessibility label of the shutter button") + static let torchOnLabel = NSLocalizedString( + "scan_torch_on_label", + value: "Turn flash on", + comment: "Accessibility label of the torch button when off") + static let torchOffLabel = NSLocalizedString( + "scan_torch_off_label", + value: "Turn flash off", + comment: "Accessibility label of the torch button when on") + static let importFromPhotosLabel = NSLocalizedString( + "scan_import_photos_label", + value: "Choose from Photos", + comment: "Button that opens the photo picker to import a receipt") + static let closeLabel = NSLocalizedString( + "scan_close_label", + value: "Close", + comment: "Accessibility label of the close button") + static let cameraUnavailableTitle = NSLocalizedString( + "scan_camera_unavailable_title", + value: "Camera not available", + comment: "Shown when the camera cannot start") + static let cameraStartingBody = NSLocalizedString( + "scan_camera_starting_body", + value: "The camera is still starting — try again in a moment.", + comment: "Shown when the shutter was tapped before the camera was ready") + static let importFailedBody = NSLocalizedString( + "scan_import_failed_body", + value: "We couldn't open that photo. Try another one, or take a new picture.", + comment: "Shown when a photo picked from the library could not be loaded") + static let cameraUnavailableBody = NSLocalizedString( + "scan_camera_unavailable_body", + value: "Allow camera access in Settings, or choose a receipt photo instead.", + comment: "Body when the camera cannot start") + static let openSettingsLabel = NSLocalizedString( + "scan_open_settings_label", + value: "Open Settings", + comment: "Button that opens the app's Settings page") + static let privacyNote = NSLocalizedString( + "scan_privacy_note", + value: "Read on your iPhone. The photo never leaves your phone.", + comment: "Reassurance shown in the scanner") + + // Reading + static let processingLabel = NSLocalizedString( + "scan_processing_label", + value: "Reading the receipt…", + comment: "Shown while OCR runs") + static let readFailedTitle = NSLocalizedString( + "scan_read_failed_title", + value: "We couldn't read this one", + comment: "Title when capture or reading fails") + static let readFailedBody = NSLocalizedString( + "scan_read_failed_body", + value: "Try again with the receipt flat and well lit, or enter the amount yourself.", + comment: "Body when capture or reading fails") + static let retryLabel = NSLocalizedString( + "scan_retry_label", + value: "Try again", + comment: "Retry button after a failed read") + + // Review + static let reviewTitle = NSLocalizedString( + "scan_review_title", + value: "Check the receipt", + comment: "Title of the review screen after a scan") + static let reviewLeadRead = NSLocalizedString( + "scan_review_lead_read", + value: "We read this — is it right?", + comment: "Lead copy on the review screen when a total was found") + static let reviewLeadUnsure = NSLocalizedString( + "scan_review_lead_unsure", + value: "We're not sure about the total — please check it.", + comment: "Lead copy on the review screen when the total is a low-confidence guess") + static let reviewLeadNone = NSLocalizedString( + "scan_review_lead_none", + value: "We couldn't find a total. Enter it below.", + comment: "Lead copy on the review screen when no amount was found") + static let alternativesLabel = NSLocalizedString( + "scan_alternatives_label", + value: "Also on the receipt", + comment: "Label above the alternative amount chips") + static let merchantPlaceholder = NSLocalizedString( + "scan_merchant_placeholder", + value: "Merchant", + comment: "Placeholder of the merchant field on the review screen") + static let dateLabel = NSLocalizedString( + "scan_date_label", + value: "Date", + comment: "Label of the date picker on the review screen") + static let dateFromReceiptNote = NSLocalizedString( + "scan_date_from_receipt_note", + value: "from the receipt", + comment: "Note next to the date when it was read from the receipt") + static let dateTodayNote = NSLocalizedString( + "scan_date_today_note", + value: "today — change it if the receipt is older", + comment: "Note next to the date when it defaulted to today") + static let retakeLabel = NSLocalizedString( + "scan_retake_label", + value: "Retake", + comment: "Button that goes back to the camera") + static let saveExpenseLabel = NSLocalizedString( + "scan_save_expense_label", + value: "Save expense", + comment: "Primary button on the review screen") + static let amountAccessibilityLabel = NSLocalizedString( + "scan_amount_accessibility_label", + value: "Expense amount", + comment: "Accessibility label of the amount on the review screen") + static let enterAmountError = NSLocalizedString( + "scan_enter_amount_error", + value: "Enter a valid amount greater than zero.", + comment: "Shown when saving with no amount") + static let scannedReceiptDescription = NSLocalizedString( + "scan_scanned_receipt_description", + value: "Scanned receipt", + comment: "Description saved when the receipt had no readable merchant") + static let receiptThumbnailLabel = NSLocalizedString( + "scan_receipt_thumbnail_label", + value: "Photo of the receipt", + comment: "Accessibility label of the receipt thumbnail") + static let exportDumpLabel = NSLocalizedString( + "scan_export_dump_label", + value: "Export OCR dump", + comment: "DEBUG-only button that shares what OCR read as JSON") } struct SplashScreen { diff --git a/Savely/Utilities/FeatureFlags.swift b/Savely/Utilities/FeatureFlags.swift index 8331f7c..4581cb4 100644 --- a/Savely/Utilities/FeatureFlags.swift +++ b/Savely/Utilities/FeatureFlags.swift @@ -36,4 +36,13 @@ enum FeatureFlags { /// absolute override — off forces light, on forces dark; the system /// appearance is not followed (owner's decision, 2026-08-16). static let darkModeEnabled = true + + /// Receipt capture surface. OFF = Savely's own Warm Meadow camera + /// (guide frame, torch, gallery import). ON = Apple's + /// VNDocumentCameraViewController (edge detection, auto-capture, + /// perspective correction, system chrome). Both feed the same OCR → + /// ReceiptParser → review screen. Kept as a flag so the two can be + /// compared on a real device — the Simulator has no camera for either + /// (decision recorded in docs/spikes/receipt-scan-spike.md, D5). + static let useSystemDocumentCamera = false } diff --git a/Savely/Utilities/Receipt/OCRLine.swift b/Savely/Utilities/Receipt/OCRLine.swift new file mode 100644 index 0000000..1ef114e --- /dev/null +++ b/Savely/Utilities/Receipt/OCRLine.swift @@ -0,0 +1,71 @@ +// +// OCRLine.swift +// Savely +// +// The parser's only input: recognized text lines with their geometry. +// Built from Vision observations by ReceiptOCR, or decoded from a JSON +// fixture in tests — the parser never sees Vision, UIKit or a camera. +// + +import Foundation + +/// A rectangle in normalized image coordinates with the origin at the +/// TOP-LEFT (x grows right, y grows down, both 0…1). Vision reports boxes +/// with a bottom-left origin; `ReceiptOCR` flips them so that "the row +/// above" always means a smaller `y` here. +struct OCRBox: Codable, Equatable, Sendable { + var x: Double + var y: Double + var width: Double + var height: Double + + init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + var minX: Double { x } + var maxX: Double { x + width } + var minY: Double { y } + var maxY: Double { y + height } + var midX: Double { x + width / 2 } + var midY: Double { y + height / 2 } + + /// Vertical overlap with another box as a fraction of the SHORTER of + /// the two heights (so a tall line and a short line on the same + /// baseline still count as one row). + func verticalOverlapRatio(with other: OCRBox) -> Double { + let overlap = min(maxY, other.maxY) - max(minY, other.minY) + let shortest = min(height, other.height) + guard overlap > 0, shortest > 0 else { return 0 } + return overlap / shortest + } +} + +/// One recognized line of text. +struct OCRLine: Codable, Equatable, Sendable { + var text: String + var box: OCRBox + /// Vision's confidence for the top candidate, 0…1. Fixtures without a + /// measured value use 1. + var confidence: Double + + init(text: String, box: OCRBox, confidence: Double = 1) { + self.text = text + self.box = box + self.confidence = confidence + } +} + +extension OCRLine { + /// Test/fixture helper: stacks plain strings top-to-bottom as full-width + /// rows of equal height, the way a single-column receipt reads. Use + /// explicit boxes when the layout (columns, split labels) matters. + static func stacked(_ texts: [String], lineHeight: Double = 0.02) -> [OCRLine] { + texts.enumerated().map { index, text in + OCRLine(text: text, box: OCRBox(x: 0, y: Double(index) * lineHeight * 1.5, width: 1, height: lineHeight)) + } + } +} diff --git a/Savely/Utilities/Receipt/ReceiptAmountGrammar.swift b/Savely/Utilities/Receipt/ReceiptAmountGrammar.swift new file mode 100644 index 0000000..2d144e0 --- /dev/null +++ b/Savely/Utilities/Receipt/ReceiptAmountGrammar.swift @@ -0,0 +1,265 @@ +// +// ReceiptAmountGrammar.swift +// Savely +// +// Reads money amounts out of OCR'd receipt text. Deliberately NOT the same +// grammar as `parseAmount` (AmountParsing.swift): that one parses what the +// user types and rejects thousands separators; a printed receipt uses them +// all the time ("1,250.00") and OCR adds its own noise ("1O0.00", "S 41.18"). +// Every guess this file makes is a *candidate* the user confirms on the +// review sheet — never something saved silently. +// + +import Foundation + +/// A money amount found in a row of receipt text. +struct ReceiptAmountToken: Equatable { + var value: Double + /// True when the token carried decimals ("41.18"), false for "250". + var hasDecimals: Bool + /// "MXN", "USD", … when a symbol or code sat next to the number, else nil. + var currency: String? + /// True when ANY marker sat next to the number — including a bare "$", + /// which names no currency but does say "this is money". + var hasCurrencyMarker: Bool + /// True when the separators could be read more than one way and we + /// picked the most likely reading (surface as an alternative, not truth). + var isAmbiguous: Bool + /// True when the number was preceded by a minus sign or wrapped in + /// parentheses — a discount or refund line. + var isNegative: Bool + /// The whitespace-token index within the row, for left/right ordering. + var tokenIndex: Int +} + +enum ReceiptAmountGrammar { + /// Longest digit run allowed in an amount. Ticket numbers, RFCs, card + /// numbers and barcodes are longer. + static let maxIntegerDigits = 7 + + /// Currency markers that may be glued to or sit next to a number. + /// Value: ISO code the marker implies (nil for the ambiguous "$"). + private static let currencyMarkers: [(marker: String, code: String?)] = [ + ("MXN", "MXN"), ("MX$", "MXN"), ("M.N.", "MXN"), ("MN", "MXN"), ("PESOS", "MXN"), + ("USD", "USD"), ("US$", "USD"), ("DLLS", "USD"), ("DLS", "USD"), ("DOLARES", "USD"), ("DOLLARS", "USD"), + ("EUR", "EUR"), ("€", "EUR"), ("$", nil), ("S/", nil), + ] + + /// Amounts in `rowText`, left to right. Date/time spans and percentages + /// are skipped first so "16/08/2026 12:34" or "IVA 16.00%" never yield + /// numbers. + static func amounts(in rowText: String) -> [ReceiptAmountToken] { + let cleaned = spaced(ReceiptDateParser.removingDateAndTimeSpans(from: rowText)) + let tokens = cleaned.replacingOccurrences(of: "\t", with: " ").split(separator: " ").map(String.init) + var found: [ReceiptAmountToken] = [] + var pendingCurrency: String?? = nil // a bare marker ("$", "MXN") waiting for the next number + var pendingMinus = false // a standalone "-" waiting for the next number + + for (index, rawToken) in tokens.enumerated() { + let token = rawToken.trimmingCharacters(in: CharacterSet(charactersIn: ":;,.()[]")) + if token.isEmpty { continue } + // Percentages are never money. + if rawToken.hasSuffix("%") || (index + 1 < tokens.count && tokens[index + 1] == "%") { continue } + + if let markerOnly = currencyMarkerOnly(token) { + pendingCurrency = .some(markerOnly) + continue + } + if rawToken == "-" || rawToken == "−" { + pendingMinus = true + continue + } + + var body = token + var currency: String? = nil + var sawMarker = false + if let (stripped, code) = strippingCurrencyMarker(from: body) { + body = stripped + currency = code + sawMarker = true + } + if !sawMarker, let pending = pendingCurrency { + currency = pending + sawMarker = true + } + // "350.00 MXN": the marker may follow the number. + if !sawMarker, index + 1 < tokens.count, let trailing = currencyMarkerOnly(tokens[index + 1]) { + currency = trailing + sawMarker = true + } + pendingCurrency = nil + + var isNegative = pendingMinus + pendingMinus = false + if body.hasPrefix("-") || body.hasPrefix("−") { body.removeFirst(); isNegative = true } + if body.hasSuffix("-") || body.hasSuffix("−") { body.removeLast(); isNegative = true } + if rawToken.hasPrefix("(") && rawToken.hasSuffix(")") { isNegative = true } + body = body.trimmingCharacters(in: CharacterSet(charactersIn: ":;,.()[]")) + + // Trailing letters glued to the number ("41.18MXN") were handled + // above; anything else attached means it is not an amount. + guard let parsed = parseNumber(body) else { continue } + // "12.00 -" — a trailing standalone minus after the number. + if index + 1 < tokens.count, tokens[index + 1] == "-" || tokens[index + 1] == "−" { isNegative = true } + found.append(ReceiptAmountToken( + value: parsed.value, + hasDecimals: parsed.hasDecimals, + currency: currency, + hasCurrencyMarker: sawMarker, + isAmbiguous: parsed.isAmbiguous, + isNegative: isNegative, + tokenIndex: index + )) + } + return found + } + + /// Puts whitespace around the characters that OCR glues to numbers + /// ("TOTAL:$41.18" → "TOTAL: $ 41.18") so whitespace tokenizing sees the + /// number on its own. Two-letter currency prefixes are rewritten to + /// their ISO code first so the "$" split does not lose them. + static func spaced(_ text: String) -> String { + var out = text + .replacingOccurrences(of: "MX$", with: " MXN ") + .replacingOccurrences(of: "US$", with: " USD ") + for glyph in ["$", "€", ":", "=", "*", "#"] { + out = out.replacingOccurrences(of: glyph, with: " \(glyph) ") + } + return out + } + + // MARK: - Number parsing + + struct ParsedNumber: Equatable { + var value: Double + var hasDecimals: Bool + var isAmbiguous: Bool + } + + /// Parses one whitespace token that should be a bare number. Accepts + /// "1,234.56", "1.234,56", "1234.56", "1234", "8.50", "8,50", "12.5", + /// "1,234" (→ 1234, ambiguous). Fixes OCR letter/digit confusion only + /// when the token is mostly digits already. Rejects long digit runs + /// (ticket numbers), letters, and anything with more than two separator + /// kinds. + static func parseNumber(_ raw: String) -> ParsedNumber? { + guard !raw.isEmpty else { return nil } + let normalized = normalizeDigits(raw) + let allowed = CharacterSet(charactersIn: "0123456789.,") + guard normalized.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } + let digits = normalized.filter(\.isNumber) + guard !digits.isEmpty else { return nil } + // Must start and end with a digit ("1,234.56", not ".56" or "1,") + guard let first = normalized.first, first.isNumber, let last = normalized.last, last.isNumber else { return nil } + + let commaCount = normalized.filter { $0 == "," }.count + let dotCount = normalized.filter { $0 == "." }.count + + // No separators: a whole amount. + if commaCount == 0 && dotCount == 0 { + guard digits.count <= maxIntegerDigits, let value = Double(digits) else { return nil } + return ParsedNumber(value: value, hasDecimals: false, isAmbiguous: false) + } + + // Both kinds present: the LAST separator is the decimal one. + if commaCount > 0 && dotCount > 0 { + guard let lastComma = normalized.lastIndex(of: ","), let lastDot = normalized.lastIndex(of: ".") else { return nil } + let decimalSep: Character = lastComma > lastDot ? "," : "." + let thousandsSep: Character = decimalSep == "," ? "." : "," + // Exactly one decimal separator; the other kind groups thousands. + guard normalized.filter({ $0 == decimalSep }).count == 1 else { return nil } + let parts = normalized.split(separator: decimalSep, omittingEmptySubsequences: false) + guard parts.count == 2 else { return nil } + let intPart = String(parts[0]), fracPart = String(parts[1]) + guard (1...2).contains(fracPart.count) else { return nil } + guard validThousandsGroups(intPart, separator: thousandsSep) else { return nil } + let intDigits = intPart.filter(\.isNumber) + guard intDigits.count <= maxIntegerDigits, let value = Double(intDigits + "." + fracPart) else { return nil } + return ParsedNumber(value: value, hasDecimals: true, isAmbiguous: false) + } + + // One kind of separator. + let sep: Character = commaCount > 0 ? "," : "." + let count = commaCount > 0 ? commaCount : dotCount + let parts = normalized.split(separator: sep, omittingEmptySubsequences: false).map(String.init) + guard parts.allSatisfy({ !$0.isEmpty }) else { return nil } + + if count == 1 { + let intPart = parts[0], tail = parts[1] + switch tail.count { + case 1, 2: + // "12.5", "41.18", "12,50" → decimal + guard intPart.count <= maxIntegerDigits, let value = Double(intPart + "." + tail) else { return nil } + return ParsedNumber(value: value, hasDecimals: true, isAmbiguous: false) + case 3: + // "1,234" / "1.234" → almost always a thousands group on a receipt. + guard intPart.count <= 3, let value = Double(intPart + tail) else { return nil } + return ParsedNumber(value: value, hasDecimals: false, isAmbiguous: true) + default: + return nil + } + } + + // Several of the same separator: thousands groups ("1.234.567"). + guard validThousandsGroups(normalized, separator: sep) else { return nil } + guard digits.count <= maxIntegerDigits, let value = Double(digits) else { return nil } + return ParsedNumber(value: value, hasDecimals: false, isAmbiguous: true) + } + + private static func validThousandsGroups(_ text: String, separator: Character) -> Bool { + let groups = text.split(separator: separator, omittingEmptySubsequences: false) + guard let head = groups.first, (1...3).contains(head.count) else { return false } + return groups.dropFirst().allSatisfy { $0.count == 3 } + } + + /// Maps the classic OCR confusions to digits, but only when the token + /// is already mostly numeric — "SOL" stays a word, "1O0.00" becomes + /// "100.00". + static func normalizeDigits(_ raw: String) -> String { + let confusable: [Character: Character] = ["O": "0", "o": "0", "l": "1", "I": "1", "S": "5", "B": "8", "Z": "2"] + let digitCount = raw.filter(\.isNumber).count + let letters = raw.filter(\.isLetter) + // At least one real digit, every letter must be a known confusion, + // and letters may not outnumber digits by more than two ("3S.OO" is + // fixed; "SOL", "1ST" and "IVA" are left alone). + guard digitCount >= 1, !letters.isEmpty, letters.allSatisfy({ confusable[$0] != nil }), + letters.count <= digitCount + 2 else { return raw } + return String(raw.map { confusable[$0] ?? $0 }) + } + + // MARK: - Currency markers + + /// "$", "MXN", "USD" on their own → the code they imply (nil for "$"). + private static func currencyMarkerOnly(_ token: String) -> String?? { + let upper = token.uppercased() + for entry in currencyMarkers where entry.marker == upper { + return .some(entry.code) + } + return nil + } + + /// Strips a currency marker glued to the number ("$41.18", "41.18MXN", + /// "MX$1,250.00"). Returns the bare number and the implied code. + private static func strippingCurrencyMarker(from token: String) -> (String, String?)? { + let upper = token.uppercased() + // "S41.18": Vision's usual misread of a glued "$" — a lone leading S + // followed by a decimal amount is the peso sign, not a 5. + if upper.hasPrefix("S"), token.count > 3 { + let rest = String(token.dropFirst()) + if let first = rest.first, first.isNumber, rest.contains(".") || rest.contains(",") { + return (rest, nil) + } + } + for entry in currencyMarkers { + if upper.hasPrefix(entry.marker) { + let stripped = String(token.dropFirst(entry.marker.count)) + if !stripped.isEmpty { return (stripped, entry.code) } + } + if upper.hasSuffix(entry.marker) { + let stripped = String(token.dropLast(entry.marker.count)) + if !stripped.isEmpty { return (stripped, entry.code) } + } + } + return nil + } +} diff --git a/Savely/Utilities/Receipt/ReceiptDateParser.swift b/Savely/Utilities/Receipt/ReceiptDateParser.swift new file mode 100644 index 0000000..427f826 --- /dev/null +++ b/Savely/Utilities/Receipt/ReceiptDateParser.swift @@ -0,0 +1,167 @@ +// +// ReceiptDateParser.swift +// Savely +// +// Finds the purchase date printed on a receipt. es-MX prints day first +// ("16/08/2026", "16-AGO-26"), en-US prints month first ("08/16/2026", +// "Aug 16, 2026"); when both readings are valid the caller's language +// signal decides. A date is only returned when it is plausible: not in the +// future, not older than `maxAge` — otherwise the expense keeps "now" and +// the review sheet asks. +// + +import Foundation + +enum ReceiptDateParser { + enum DayMonthOrder { case dayFirst, monthFirst } + + /// A date match with its position in the row (for stripping) and the + /// time found on the same row, if any. + struct Match: Equatable { + var date: Date + var hadTime: Bool + } + + /// Receipts older than this are treated as unreadable dates. + static let maxAge: TimeInterval = 2 * 365 * 24 * 3600 + + /// Abbreviations and full names, es + en. Matched EXACTLY — a prefix + /// match would turn "2 MARGARITAS 26.00" into a date in March. + private static let monthNames: [String: Int] = [ + "ENE": 1, "FEB": 2, "MAR": 3, "ABR": 4, "MAY": 5, "JUN": 6, "JUL": 7, "AGO": 8, "SEP": 9, "SEPT": 9, + "SET": 9, "OCT": 10, "NOV": 11, "DIC": 12, + "JAN": 1, "APR": 4, "AUG": 8, "DEC": 12, + "ENERO": 1, "FEBRERO": 2, "MARZO": 3, "ABRIL": 4, "MAYO": 5, "JUNIO": 6, "JULIO": 7, "AGOSTO": 8, + "SEPTIEMBRE": 9, "SETIEMBRE": 9, "OCTUBRE": 10, "NOVIEMBRE": 11, "DICIEMBRE": 12, + "JANUARY": 1, "FEBRUARY": 2, "MARCH": 3, "APRIL": 4, "JUNE": 6, "JULY": 7, "AUGUST": 8, + "SEPTEMBER": 9, "OCTOBER": 10, "NOVEMBER": 11, "DECEMBER": 12, + ] + + // Numeric: 16/08/2026, 16-08-26, 16.08.2026, 08/16/2026 + private static let numericPattern = #"(? NSRegularExpression? { + try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) + } + + // MARK: - Public + + /// The plausibility window every candidate date must pass: not in the + /// future (one day of slack for time zones) and not older than `maxAge`. + static func isPlausible(_ date: Date, now: Date) -> Bool { + date <= now.addingTimeInterval(24 * 3600) && now.timeIntervalSince(date) <= maxAge + } + + /// Every plausible date on the row, in reading order. + static func dates( + in rowText: String, + order: DayMonthOrder, + now: Date, + calendar: Calendar = .current + ) -> [Match] { + let text = rowText.uppercased() + let hadTime = regex(timePattern)?.firstMatch(in: text, range: fullRange(text)) != nil + var results: [Match] = [] + + for (pattern, kind) in [(isoPattern, Kind.iso), (dayMonthNamePattern, .dayMonthName), + (monthNameDayPattern, .monthNameDay), (numericPattern, .numeric)] { + guard let re = regex(pattern) else { continue } + for m in re.matches(in: text, range: fullRange(text)) { + let groups = (1.. String? in + guard let r = Range(m.range(at: i), in: text) else { return nil } + return String(text[r]) + } + guard groups.count == 3 else { continue } + if let date = makeDate(groups: groups, kind: kind, order: order, now: now, calendar: calendar) { + results.append(Match(date: date, hadTime: hadTime)) + } + } + } + return results + } + + /// The row with any date/time spans blanked out — so the amount grammar + /// never mistakes "16/08/2026" or "12:34" for money. + static func removingDateAndTimeSpans(from rowText: String) -> String { + var text = rowText + for pattern in [isoPattern, numericPattern, timePattern] { + guard let re = regex(pattern) else { continue } + text = re.stringByReplacingMatches(in: text, range: fullRange(text), withTemplate: " ") + } + return text + } + + /// Picks the language signal for ambiguous numeric dates: Spanish + /// vocabulary anywhere on the receipt ⇒ day first; else the locale. + static func dayMonthOrder(normalizedRows: [String], locale: Locale) -> DayMonthOrder { + // Language-specific words only — TOTAL, SUBTOTAL, VISA appear on + // both sides of the border and would only add noise. + let spanishMarkers = ["IVA", "EFECTIVO", "CAMBIO", "PROPINA", "IMPORTE", "TOTAL A PAGAR", "GRACIAS", + "FECHA", "TARJETA", "PAGO", "ARTICULOS", "FOLIO", "CAJA", "RFC", "TICKET", + "TIENDA", "SUCURSAL", "CLIENTE"] + let englishMarkers = ["TAX", "CASH", "CHANGE", "TENDERED", "AMOUNT DUE", "THANK", "THANKS", + "DEBIT", "CREDIT", "TIP", "STORE", "ITEMS", "SALE", "PURCHASE", "CASHIER"] + var spanish = 0, english = 0 + for row in normalizedRows { + for w in spanishMarkers where ReceiptText.containsPhrase(row, w) { spanish += 1 } + for w in englishMarkers where ReceiptText.containsPhrase(row, w) { english += 1 } + } + if spanish > english { return .dayFirst } + if english > spanish { return .monthFirst } + return locale.language.languageCode?.identifier == "es" ? .dayFirst : .monthFirst + } + + // MARK: - Private + + private enum Kind { case iso, dayMonthName, monthNameDay, numeric } + + private static func fullRange(_ s: String) -> NSRange { NSRange(s.startIndex..., in: s) } + + private static func makeDate( + groups: [String], kind: Kind, order: DayMonthOrder, now: Date, calendar: Calendar + ) -> Date? { + var day = 0, month = 0, year = 0 + switch kind { + case .iso: + guard let y = Int(groups[0]), let m = Int(groups[1]), let d = Int(groups[2]) else { return nil } + (year, month, day) = (y, m, d) + case .dayMonthName: + guard let d = Int(groups[0]), let m = monthNumber(groups[1]), let y = Int(groups[2]) else { return nil } + (year, month, day) = (expandYear(y), m, d) + case .monthNameDay: + guard let m = monthNumber(groups[0]), let d = Int(groups[1]), let y = Int(groups[2]) else { return nil } + (year, month, day) = (expandYear(y), m, d) + case .numeric: + guard let a = Int(groups[0]), let b = Int(groups[1]), let y = Int(groups[2]) else { return nil } + year = expandYear(y) + if a > 12 && b <= 12 { (day, month) = (a, b) } + else if b > 12 && a <= 12 { (day, month) = (b, a) } + else if order == .dayFirst { (day, month) = (a, b) } + else { (day, month) = (b, a) } + } + guard (1...12).contains(month), (1...31).contains(day), year >= 2000 else { return nil } + var comps = DateComponents() + comps.year = year; comps.month = month; comps.day = day; comps.hour = 12 + guard let date = calendar.date(from: comps) else { return nil } + // Reject impossible days (Feb 30) and implausible dates. + guard calendar.component(.day, from: date) == day else { return nil } + guard isPlausible(date, now: now) else { return nil } + return date + } + + private static func monthNumber(_ raw: String) -> Int? { + let key = raw.folding(options: .diacriticInsensitive, locale: nil).uppercased() + return monthNames[key] + } + + private static func expandYear(_ y: Int) -> Int { y < 100 ? 2000 + y : y } +} diff --git a/Savely/Utilities/Receipt/ReceiptParser.swift b/Savely/Utilities/Receipt/ReceiptParser.swift new file mode 100644 index 0000000..70ea43b --- /dev/null +++ b/Savely/Utilities/Receipt/ReceiptParser.swift @@ -0,0 +1,488 @@ +// +// ReceiptParser.swift +// Savely +// +// Turns OCR lines (text + boxes) into a draft expense: the most likely +// total, the other amounts worth offering, the merchant, the printed date +// and a category hint. Pure and deterministic — every rule here is covered +// by ReceiptParserTests over JSON fixtures, and every output is a +// *suggestion* the review sheet shows the user before anything is saved. +// +// Pipeline: lines → rows (clustered by vertical overlap, sorted left→right) +// → amount candidates per row (ReceiptAmountGrammar) → label class per row +// (ReceiptVocabulary) → score → pick + rank alternatives → merchant / date / +// category from the same rows. +// + +import Foundation + +/// One amount the parser considered, with why it scored the way it did. +struct ReceiptCandidate: Equatable { + var value: Double + var currency: String? + /// The text of the row it was found on — shown as context for alternatives. + var rowText: String + var rowIndex: Int + var label: ReceiptLabelClass + var score: Double +} + +/// What the parser thinks about its own answer. +enum ReceiptConfidence: Equatable { + /// A total-labelled amount with a clear margin. + case high + /// A total-labelled amount, but another candidate is close. + case medium + /// No total label found — best guess from position/size only. + case low + /// No amount at all. + case none +} + +struct ReceiptDraft: Equatable { + var total: ReceiptCandidate? + /// Other amounts, best first, distinct values, never the chosen total. + var alternatives: [ReceiptCandidate] + var confidence: ReceiptConfidence + var merchant: String? + /// The date printed on the receipt when one plausible date was found. + var date: Date? + /// "MXN" / "USD" when the receipt said so; nil otherwise. + var currencyHint: String? + var categoryHint: ExpenseCategory? + /// Rows as the parser saw them (top→bottom), for debugging and fixtures. + var rows: [String] + + static let empty = ReceiptDraft(total: nil, alternatives: [], confidence: .none, merchant: nil, + date: nil, currencyHint: nil, categoryHint: nil, rows: []) +} + +enum ReceiptParser { + /// A visual row of the receipt. + struct Row: Equatable { + var index: Int + var text: String + var normalized: String + var box: OCRBox + var lineHeight: Double + var amounts: [ReceiptAmountToken] + var label: ReceiptLabelClass + } + + /// Two lines are on the same row when their boxes overlap vertically by + /// at least this fraction of the shorter one. + static let rowOverlapThreshold = 0.5 + /// Amounts above this are ticket numbers, not purchases, for this app. + static let maxPlausibleTotal: Double = 500_000 + /// Alternatives offered on the review sheet. + static let maxAlternatives = 4 + + // MARK: - Entry point + + static func parse( + lines: [OCRLine], + now: Date = Date(), + locale: Locale = .current, + calendar: Calendar = .current + ) -> ReceiptDraft { + let rows = buildRows(from: lines) + guard !rows.isEmpty else { return .empty } + + let candidates = scoreCandidates(rows: rows, locale: locale) + let ranked = candidates.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + return lhs.rowIndex > rhs.rowIndex // lower on the receipt wins ties + } + let total = ranked.first + let alternatives = rankAlternatives(ranked, excluding: total) + let confidence = confidence(for: total, runnerUp: alternatives.first) + let normalizedRows = rows.map(\.normalized) + let merchant = merchant(rows: rows) + let categoryHint = categoryHint(merchant: merchant, rows: rows) + let order = ReceiptDateParser.dayMonthOrder(normalizedRows: normalizedRows, locale: locale) + let date = receiptDate(rows: rows, order: order, now: now, calendar: calendar) + let currencyHint = total?.currency ?? candidates.compactMap(\.currency).first + + return ReceiptDraft( + total: total, + alternatives: alternatives, + confidence: confidence, + merchant: merchant, + date: date, + currencyHint: currencyHint, + categoryHint: categoryHint, + rows: rows.map(\.text) + ) + } + + // MARK: - Rows + + /// Clusters lines into rows by vertical overlap, top to bottom, and + /// orders each row's lines left to right. Vision's observation order is + /// not reading order and a "TOTAL 41.18" line often arrives as two + /// observations — this is what glues them back together. + static func buildRows(from lines: [OCRLine]) -> [Row] { + let sorted = lines + .filter { !$0.text.trimmingCharacters(in: .whitespaces).isEmpty } + .sorted { $0.box.midY < $1.box.midY } + var clusters: [[OCRLine]] = [] + var clusterBoxes: [OCRBox] = [] + + for line in sorted { + if let last = clusterBoxes.indices.last, + line.box.verticalOverlapRatio(with: clusterBoxes[last]) >= rowOverlapThreshold { + clusters[last].append(line) + clusterBoxes[last] = union(clusterBoxes[last], line.box) + } else { + clusters.append([line]) + clusterBoxes.append(line.box) + } + } + + return clusters.enumerated().map { index, cluster in + let ordered = cluster.sorted { $0.box.minX < $1.box.minX } + let text = ordered.map { $0.text.trimmingCharacters(in: .whitespaces) }.joined(separator: " ") + let normalized = ReceiptText.normalized(text) + return Row( + index: index, + text: text, + normalized: normalized, + box: clusterBoxes[index], + lineHeight: ordered.map(\.box.height).max() ?? 0, + amounts: ReceiptAmountGrammar.amounts(in: text), + label: ReceiptVocabulary.labelClass(of: normalized) + ) + } + } + + private static func union(_ a: OCRBox, _ b: OCRBox) -> OCRBox { + let minX = min(a.minX, b.minX), minY = min(a.minY, b.minY) + let maxX = max(a.maxX, b.maxX), maxY = max(a.maxY, b.maxY) + return OCRBox(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + } + + // MARK: - Scoring + + private struct Weights { + static let totalStrong = 10.0 + static let total = 8.0 + static let totalWithTip = 4.0 + static let excluded = -8.0 + static let labelFromRowAbove = -1.0 // a label borrowed from the previous row is a bit weaker + static let bottomMostTotal = 2.0 + static let hasDecimals = 1.0 + static let noDecimals = -1.0 + static let tinyValue = -3.0 + static let rightAlignedColumn = 1.0 + static let mathConsistent = 3.0 + static let tallText = 1.0 + static let largestAmount = 1.0 + static let repeatedValue = 1.0 + static let ambiguousToken = -1.0 + static let currencyMismatch = -3.0 + static let currencyMatch = 1.0 + static let implausible = -20.0 + } + + /// Every non-negative amount on the receipt, scored. + static func scoreCandidates(rows: [Row], locale: Locale) -> [ReceiptCandidate] { + var candidates: [ReceiptCandidate] = [] + let medianHeight = median(rows.map(\.lineHeight)) + let rightEdge = dominantRightEdge(rows: rows) + let localCurrency = locale.currency?.identifier + + // The label of a row that has none can be borrowed from the row + // directly above when that row carries a total label and no amount: + // TOTAL + // 41.18 + var effectiveLabels: [Int: (ReceiptLabelClass, Bool)] = [:] + for row in rows { + var label = row.label + var borrowed = false + if label == .none, row.index > 0 { + let above = rows[row.index - 1] + let aboveWords = above.normalized.split(separator: " ") + let isColumnHeader = ReceiptVocabulary.columnHeaderWords.contains { ReceiptText.containsPhrase(above.normalized, $0) } + // Borrow only from a row that is essentially just the label + // ("TOTAL", "SUBTOTAL:"), never from a column header like + // "CANT DESCRIPCION IMPORTE". + if above.amounts.isEmpty, above.label != .none, above.label != .excluded(.other), + aboveWords.count <= 3, !isColumnHeader { + label = above.label + borrowed = true + } + } + effectiveLabels[row.index] = (label, borrowed) + } + + // Values used by the math check. + func values(labelled target: ReceiptLabelClass) -> [Double] { + rows.filter { effectiveLabels[$0.index]?.0 == target } + .flatMap { $0.amounts.filter { !$0.isNegative }.map(\.value) } + } + let subtotals = values(labelled: .excluded(.subtotal)) + let taxes = values(labelled: .excluded(.tax)) + let cash = values(labelled: .excluded(.cash)) + let change = values(labelled: .excluded(.change)) + let allValues = rows.flatMap { $0.amounts.filter { !$0.isNegative }.map(\.value) } + let largest = allValues.max() ?? 0 + + // Bottom-most row carrying a real total label. + let totalRowIndices = rows.compactMap { row -> Int? in + guard let entry = effectiveLabels[row.index] else { return nil } + switch entry.0 { + case .total, .totalStrong: return row.amounts.contains { !$0.isNegative } ? row.index : nil + default: return nil + } + } + let bottomTotalRow = totalRowIndices.max() + + // Currency only matters when the receipt prints more than one + // (border receipts with TOTAL MXN and TOTAL USD): then the row in the + // user's currency wins and the other becomes the first alternative. + let currenciesOnReceipt = Set(rows.flatMap { row in + row.amounts.compactMap(\.currency) + [rowCurrencyCode(row)].compactMap { $0 } + }) + let isDualCurrency = currenciesOnReceipt.count >= 2 + + for row in rows { + guard let entry = effectiveLabels[row.index] else { continue } + let (label, borrowed) = entry + let rowCurrency = rowCurrencyCode(row) + let rowIsTotalLabelled: Bool + switch label { + case .total, .totalStrong, .totalWithTip: rowIsTotalLabelled = !borrowed + case .excluded, .none: rowIsTotalLabelled = false + } + for token in row.amounts where !token.isNegative { + // Receipts print money with decimals. A bare integer only counts + // when something says it is money: a currency marker ("$250", + // "MXN 250") or a total label printed on the same row. That + // drops quantities, phone numbers, store/ticket ids and the + // masked card digits on TARJETA/VISA rows ("****1234"). + if !token.hasDecimals, !token.hasCurrencyMarker, rowCurrency == nil, !rowIsTotalLabelled { continue } + var score = 0.0 + switch label { + case .totalStrong: score += Weights.totalStrong + case .total: score += Weights.total + case .totalWithTip: score += Weights.totalWithTip + case .excluded: score += Weights.excluded + case .none: break + } + if borrowed { score += Weights.labelFromRowAbove } + if let bottom = bottomTotalRow, bottom == row.index, label == .total || label == .totalStrong { + score += Weights.bottomMostTotal + } + score += token.hasDecimals ? Weights.hasDecimals : Weights.noDecimals + if token.value < 1 { score += Weights.tinyValue } + if token.value > maxPlausibleTotal { score += Weights.implausible } + if token.isAmbiguous { score += Weights.ambiguousToken } + if let edge = rightEdge, abs(row.box.maxX - edge) <= 0.06 { score += Weights.rightAlignedColumn } + if medianHeight > 0, row.lineHeight >= medianHeight * 1.25 { score += Weights.tallText } + if token.value == largest, largest > 0 { score += Weights.largestAmount } + if allValues.filter({ approximatelyEqual($0, token.value) }).count > 1 { score += Weights.repeatedValue } + if isMathConsistent(token.value, subtotals: subtotals, taxes: taxes, cash: cash, change: change) { + score += Weights.mathConsistent + } + let currency = token.currency ?? rowCurrency + if isDualCurrency, let currency, let localCurrency { + score += currency == localCurrency ? Weights.currencyMatch : Weights.currencyMismatch + } + candidates.append(ReceiptCandidate( + value: token.value, currency: currency, rowText: row.text, + rowIndex: row.index, label: label, score: score + )) + } + } + return candidates + } + + /// x == subtotal + tax | x == subtotal + Σtaxes | x == cash − change + private static func isMathConsistent(_ x: Double, subtotals: [Double], taxes: [Double], cash: [Double], change: [Double]) -> Bool { + for s in subtotals { + for t in taxes where approximatelyEqual(s + t, x) { return true } + if !taxes.isEmpty, approximatelyEqual(s + taxes.reduce(0, +), x) { return true } + } + for c in cash { + for ch in change where approximatelyEqual(c - ch, x) { return true } + } + return false + } + + static func approximatelyEqual(_ a: Double, _ b: Double, tolerance: Double = 0.011) -> Bool { + abs(a - b) <= tolerance + } + + /// The right edge most amount-bearing rows share (the price column). + private static func dominantRightEdge(rows: [Row]) -> Double? { + let edges = rows.filter { !$0.amounts.isEmpty }.map(\.box.maxX) + guard edges.count >= 2 else { return nil } + return median(edges) + } + + private static func median(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + return sorted[sorted.count / 2] + } + + /// "TOTAL MXN", "TOTAL USD", "TOTAL M.N." on the row → a currency code. + private static func rowCurrencyCode(_ row: Row) -> String? { + let n = row.normalized + if ReceiptText.containsPhrase(n, "USD") || ReceiptText.containsPhrase(n, "DLLS") || ReceiptText.containsPhrase(n, "DLS") { + return "USD" + } + if ReceiptText.containsPhrase(n, "MXN") || ReceiptText.containsPhrase(n, "M N") || ReceiptText.containsPhrase(n, "MN") + || ReceiptText.containsPhrase(n, "PESOS") { + return "MXN" + } + return nil + } + + // MARK: - Ranking + + /// What to offer when the chosen total is wrong: another total row (dual + /// currency, total-with-tip), then the subtotal, then what was charged + /// to a card or paid in cash, then plain amounts — never tax, change or + /// counters first. Sorted by that priority, then by the parser score. + static func alternativePriority(_ candidate: ReceiptCandidate) -> Int { + switch candidate.label { + case .totalStrong, .total, .totalWithTip: return 6 + case .excluded(.subtotal): return 5 + case .excluded(.card): return 4 + case .excluded(.cash): return 3 + case .none: return 2 + case .excluded(.tax), .excluded(.tip), .excluded(.change), .excluded(.other): return 0 + } + } + + private static func rankAlternatives(_ ranked: [ReceiptCandidate], excluding total: ReceiptCandidate?) -> [ReceiptCandidate] { + var seen: [Double] = [] + if let total { seen.append(total.value) } + var out: [ReceiptCandidate] = [] + let byPriority = ranked.sorted { lhs, rhs in + let lp = alternativePriority(lhs), rp = alternativePriority(rhs) + if lp != rp { return lp > rp } + if lhs.score != rhs.score { return lhs.score > rhs.score } + return lhs.value > rhs.value + } + for c in byPriority { + if seen.contains(where: { approximatelyEqual($0, c.value) }) { continue } + if c.value <= 0 || c.value > maxPlausibleTotal { continue } + seen.append(c.value) + out.append(c) + if out.count == maxAlternatives { break } + } + return out + } + + private static func confidence(for total: ReceiptCandidate?, runnerUp: ReceiptCandidate?) -> ReceiptConfidence { + guard let total, total.value > 0 else { return .none } + let labelled: Bool + switch total.label { + case .total, .totalStrong: labelled = true + default: labelled = false + } + guard labelled else { return .low } + let gap = total.score - (runnerUp?.score ?? -100) + return gap >= 3 ? .high : .medium + } + + // MARK: - Merchant + + /// The most prominent row near the top that reads like a name: a known + /// brand wins outright; otherwise the tallest non-noise row among the + /// first quarter of the receipt, top-most on ties. + static func merchant(rows: [Row]) -> String? { + let topCount = max(3, Int((Double(rows.count) * 0.3).rounded(.up))) + let top = Array(rows.prefix(topCount)) + + for row in top { + for brand in ReceiptVocabulary.brands where ReceiptText.containsPhrase(row.normalized, brand.phrase) { + return brand.name + } + } + + let candidates = top.filter { isMerchantCandidate($0) } + guard let best = candidates.max(by: { lhs, rhs in + if lhs.lineHeight != rhs.lineHeight { return lhs.lineHeight < rhs.lineHeight } + return lhs.index > rhs.index // prefer the earlier row on equal height + }) else { return nil } + return prettifiedMerchant(best.text) + } + + private static func isMerchantCandidate(_ row: Row) -> Bool { + guard row.amounts.isEmpty, row.label == .none else { return false } + let letters = row.text.filter(\.isLetter).count + let digits = row.text.filter(\.isNumber).count + guard letters >= 3, letters > digits else { return false } + for marker in ReceiptVocabulary.merchantDisqualifiers where ReceiptText.containsPhrase(row.normalized, marker) { + return false + } + if row.text.contains("@") || row.text.lowercased().contains("www.") || row.text.lowercased().contains(".com") { + return false + } + if ReceiptDateParser.removingDateAndTimeSpans(from: row.text) != row.text { return false } + let words = row.normalized.split(separator: " ").map(String.init) + let meaningful = words.filter { !ReceiptVocabulary.merchantNoiseWords.contains($0) && !$0.allSatisfy(\.isNumber) } + return !meaningful.isEmpty + } + + /// Trims legal suffixes and turns ALL-CAPS thermal print into a name + /// ("TIENDAS DEL SOL S.A. DE C.V." → "Tiendas Del Sol"). + static func prettifiedMerchant(_ raw: String) -> String { + var normalized = ReceiptText.normalized(raw) + for suffix in ReceiptVocabulary.legalSuffixes where normalized.hasSuffix(" \(suffix) ") { + normalized = String(normalized.dropLast(suffix.count + 1)) + break + } + let words = normalized.split(separator: " ").map(String.init) + // Keep the original casing (and punctuation: "Joe's Café") when it + // was not all caps — OCR of a mixed-case logo is usually right. + let isAllCaps = raw.filter(\.isLetter).allSatisfy(\.isUppercase) + if !isAllCaps { + return raw.split(separator: " ").joined(separator: " ").trimmingCharacters(in: .whitespaces) + } + return words.map { word -> String in + // Short vowel-less words are acronyms (HEB, CVS, KFC); everything + // else reads better as a name ("Don", "La", "Del", "Sol"). + let hasVowel = word.contains { "AEIOU".contains($0) } + if word.count <= 3, !hasVowel { return word } + return word.prefix(1) + word.dropFirst().lowercased() + }.joined(separator: " ") + } + + // MARK: - Date + + /// The most likely purchase date: prefer a row that also shows a time or + /// a FECHA/DATE label; otherwise the first plausible date found. + static func receiptDate(rows: [Row], order: ReceiptDateParser.DayMonthOrder, now: Date, calendar: Calendar) -> Date? { + var fallback: Date? + for row in rows { + let matches = ReceiptDateParser.dates(in: row.text, order: order, now: now, calendar: calendar) + guard let first = matches.first else { continue } + let labelled = ReceiptText.containsPhrase(row.normalized, "FECHA") || ReceiptText.containsPhrase(row.normalized, "DATE") + if first.hadTime || labelled { return first.date } + if fallback == nil { fallback = first.date } + } + return fallback + } + + // MARK: - Category + + static func categoryHint(merchant: String?, rows: [Row]) -> ExpenseCategory? { + var haystacks: [String] = [] + if let merchant { haystacks.append(ReceiptText.normalized(merchant)) } + let topCount = max(3, Int((Double(rows.count) * 0.3).rounded(.up))) + haystacks.append(contentsOf: rows.prefix(topCount).map(\.normalized)) + // The merchant name is the strongest signal, so it is checked against + // every keyword before any other row is ("Abarrotes Don Pepe" is a + // grocery even when the first item is coffee). + for text in haystacks { + for entry in ReceiptVocabulary.categoryKeywords where ReceiptText.containsPhrase(text, entry.phrase) { + return entry.category + } + } + return nil + } +} diff --git a/Savely/Utilities/Receipt/ReceiptVocabulary.swift b/Savely/Utilities/Receipt/ReceiptVocabulary.swift new file mode 100644 index 0000000..f303168 --- /dev/null +++ b/Savely/Utilities/Receipt/ReceiptVocabulary.swift @@ -0,0 +1,292 @@ +// +// ReceiptVocabulary.swift +// Savely +// +// The words the parser looks for on es-MX / en-US receipts, in one place: +// which labels mean "this is what you paid", which mean "this is NOT it" +// (subtotal, tax, cash tendered, change, tip…), the brands we recognise, +// and the keywords that hint at one of the five expense chips. +// +// All matching happens on `ReceiptText.normalized(_:)` output: uppercase, +// accents folded, punctuation collapsed to spaces, padded with one space +// on each side — so `" TOTAL "` matches the word and not `SUBTOTAL`. +// + +import Foundation + +enum ReceiptText { + /// Uppercases, folds accents (`CAMBIÓ` → `CAMBIO`), turns every + /// non-alphanumeric character into a space (`SUB-TOTAL:` → `SUB TOTAL`), + /// collapses runs of spaces, and pads the result with a single space on + /// both ends so callers can match whole words with `contains(" WORD ")`. + static func normalized(_ text: String) -> String { + let folded = text.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US")) + .uppercased() + var out = " " + var lastWasSpace = true + for scalar in folded.unicodeScalars { + let isAlnum = CharacterSet.alphanumerics.contains(scalar) + if isAlnum { + out.unicodeScalars.append(scalar) + lastWasSpace = false + } else if !lastWasSpace { + out.append(" ") + lastWasSpace = true + } + } + if !lastWasSpace { out.append(" ") } + return out + } + + /// True when the padded, normalized `haystack` contains `phrase` as + /// whole words. `phrase` is written in normal form already (uppercase, + /// single spaces, no accents). + static func containsPhrase(_ haystack: String, _ phrase: String) -> Bool { + haystack.contains(" \(phrase) ") + } +} + +/// How a row's label relates to the amount printed on it. +enum ReceiptLabelClass: Equatable { + /// "TOTAL A PAGAR", "GRAND TOTAL", "AMOUNT DUE" — the strongest signal. + case totalStrong + /// "TOTAL", "IMPORTE" — what most receipts print. + case total + /// "TOTAL CON PROPINA" / "TOTAL WITH TIP" — a real total, but usually not + /// the one the user thinks of first; kept as an alternative. + case totalWithTip + /// Subtotal / tax / cash tendered / change / card / points… — an amount + /// that must never be picked as the total. + case excluded(ReceiptExclusion) + case none +} + +/// Why an amount is excluded — some exclusions feed the math check. +enum ReceiptExclusion: Equatable { + case subtotal + case tax + case tip + case cash + case change + case card + case other +} + +enum ReceiptVocabulary { + // MARK: Labels + + /// Ordered from most to least specific: the first phrase that matches + /// decides the class. Phrases are in `ReceiptText.normalized` form. + static let totalStrongPhrases: [String] = [ + "TOTAL A PAGAR", "TOTAL PAGAR", "IMPORTE TOTAL", "IMPORTE A PAGAR", "GRAN TOTAL", + "GRAND TOTAL", "AMOUNT DUE", "BALANCE DUE", "TOTAL DUE", "TOTAL VENTA", "TOTAL DE LA VENTA", + "TOTAL COMPRA", "TOTAL FACTURA", "TOTAL TICKET", "TOTAL NOTA", "TOTAL GENERAL", + ] + static let totalWithTipPhrases: [String] = [ + "TOTAL CON PROPINA", "TOTAL C PROPINA", "TOTAL PROPINA INCLUIDA", "TOTAL WITH TIP", + "TOTAL WITH GRATUITY", "TOTAL INCL TIP", + ] + /// Rows that say TOTAL together with a payment/tax word and still mean + /// "what you paid" — checked BEFORE the exclusions so "TOTAL PAGADO" or + /// "TOTAL IVA INCLUIDO" are not demoted by PAGADO / IVA. + static let totalOverridePhrases: [String] = [ + "TOTAL IVA INCLUIDO", "TOTAL CON IVA", "TOTAL INCLUYE IVA", "TOTAL INC IVA", "TOTAL PAGADO", + "TOTAL PAID", "TOTAL PAGO", "TOTAL TARJETA", "TOTAL CARD", "TOTAL EFECTIVO", "TOTAL CASH", + "TOTAL COBRADO", "TOTAL CHARGED", "IMPORTE PAGADO", "IMPORTE COBRADO", + ] + static let totalPhrases: [String] = [ + "TOTAL", "T0TAL", "TOTAL MXN", "TOTAL USD", "TOTAL MN", "TOTAL M N", "TOTAL DLLS", "TOTAL PESOS", + "IMPORTE", "A PAGAR", "MONTO TOTAL", "MONTO", "NETO A PAGAR", "TOTAL NETO", + ] + + /// Exclusions checked BEFORE the total phrases (so "SUBTOTAL" and + /// "TOTAL ARTICULOS" never count as a total). Order matters only within + /// a class; the first hit wins. + static let exclusionPhrases: [(phrase: String, kind: ReceiptExclusion)] = [ + ("SUBTOTAL", .subtotal), ("SUB TOTAL", .subtotal), ("SUBT0TAL", .subtotal), ("SUBTOT", .subtotal), + ("SUB TOT", .subtotal), ("SUBTOTALES", .subtotal), + ("IVA", .tax), ("I V A", .tax), ("IEPS", .tax), ("TAX", .tax), ("TAXES", .tax), ("VAT", .tax), + ("SALES TAX", .tax), ("IMPUESTO", .tax), ("IMPUESTOS", .tax), ("BASE GRAVABLE", .tax), ("TASA", .tax), + ("PROPINA", .tip), ("TIP", .tip), ("GRATUITY", .tip), ("SERVICIO", .tip), + ("EFECTIVO", .cash), ("CASH", .cash), ("PAGO EN EFECTIVO", .cash), ("RECIBIDO", .cash), + ("SU PAGO", .cash), ("TENDERED", .cash), ("TENDER", .cash), ("TEND", .cash), ("PAGO", .cash), + ("PAGADO", .cash), ("PAID", .cash), ("RECIBIMOS", .cash), ("ENTREGADO", .cash), + ("CAMBIO", .change), ("CHANGE", .change), ("SU CAMBIO", .change), ("VUELTO", .change), + ("TARJETA", .card), ("CARD", .card), ("VISA", .card), ("MASTERCARD", .card), ("AMEX", .card), + ("DEBITO", .card), ("CREDITO", .card), ("DEBIT", .card), ("CREDIT", .card), ("TDC", .card), + ("TDD", .card), ("APPLE PAY", .card), ("CLIP", .card), ("TERMINAL", .card), ("APROBADO", .card), + ("AUTORIZACION", .card), ("AUTH", .card), ("AUTORIZ", .card), + ("PUNTOS", .other), ("POINTS", .other), ("AHORRO", .other), ("AHORRASTE", .other), ("SAVINGS", .other), + ("YOU SAVED", .other), ("DESCUENTO", .other), ("DESCUENTOS", .other), ("DISCOUNT", .other), + ("ARTICULOS", .other), ("ARTICULO", .other), ("ITEMS", .other), ("ITEM COUNT", .other), + ("PIEZAS", .other), ("PZAS", .other), ("UNIDADES", .other), ("QTY", .other), ("CANTIDAD", .other), + ("SALDO", .other), ("BALANCE ANTERIOR", .other), ("REDONDEO", .other), ("DONATIVO", .other), + ("DONACION", .other), ("ENVIO", .other), ("SHIPPING", .other), ("DELIVERY", .other), + ("COMISION", .other), ("FEE", .other), ("CARGO", .other), ("PRECIO UNITARIO", .other), + ("PRECIO", .other), ("P UNIT", .other), ("TIPO DE CAMBIO", .other), + ("FOLIO", .other), ("TICKET", .other), ("CAJA", .other), ("CAJERO", .other), + ("RFC", .other), ("REFERENCIA", .other), + ("HORA", .other), ("FECHA", .other), ("DATE", .other), ("TIME", .other), ("CLIENTE", .other), + ("SUCURSAL", .other), ("TIENDA", .other), ("STORE", .other), + ] + + /// Classifies a normalized row string. + static func labelClass(of normalizedRow: String) -> ReceiptLabelClass { + for phrase in totalWithTipPhrases where ReceiptText.containsPhrase(normalizedRow, phrase) { + return .totalWithTip + } + for phrase in totalStrongPhrases where ReceiptText.containsPhrase(normalizedRow, phrase) { + return .totalStrong + } + // A total-strong or override phrase beats an exclusion word on the + // same row ("TOTAL A PAGAR CON TARJETA", "TOTAL PAGADO" are still the + // total); anything else with an exclusion word is out. + for phrase in totalOverridePhrases where ReceiptText.containsPhrase(normalizedRow, phrase) { + return .total + } + for entry in exclusionPhrases where ReceiptText.containsPhrase(normalizedRow, entry.phrase) { + return .excluded(entry.kind) + } + for phrase in totalPhrases where ReceiptText.containsPhrase(normalizedRow, phrase) { + return .total + } + return .none + } + + /// Words that, when the whole row is only these plus digits, mark it as + /// noise for merchant detection (greetings, document words, headers). + static let merchantNoiseWords: Set = [ + "BIENVENIDO", "BIENVENIDOS", "WELCOME", "GRACIAS", "THANK", "THANKS", "YOU", "POR", "SU", "COMPRA", + "VISITA", "VUELVA", "PRONTO", "TICKET", "TIKET", "FACTURA", "NOTA", "DE", "VENTA", "COMPROBANTE", + "ORIGINAL", "COPIA", "CLIENTE", "RECIBO", "RECEIPT", "INVOICE", "ORDER", "ORDEN", "PEDIDO", "MESA", + "TABLE", "MESERO", "SERVER", "CAJA", "CAJERO", "CASHIER", "REGISTER", "FOLIO", "NO", "NUM", "SUC", + "SUCURSAL", "TIENDA", "STORE", "TEL", "TELEFONO", "PHONE", "RFC", "R", "F", "C", "CP", "COL", + "COLONIA", "CALLE", "AV", "AVE", "AVENIDA", "BLVD", "BOULEVARD", "CARRETERA", "KM", "LOCAL", "PLAZA", + "MEXICO", "TIJUANA", "B", "BC", "BAJA", "CALIFORNIA", "CA", "USA", "SAN", "DIEGO", "CHULA", "VISTA", + "WWW", "COM", "MX", "HTTP", "HTTPS", "FECHA", "HORA", "DATE", "TIME", "TERMINAL", "OPERADOR", + "ATENDIO", "LE", "ATENDIO", "VENDEDOR", "TRANSACCION", "TRANS", "AUTORIZACION", "APROBADA", + "DUPLICADO", "REIMPRESION", "PAGINA", "PAGE", + ] + + /// Row-level markers that disqualify a row as the merchant name. + static let merchantDisqualifiers: [String] = [ + "RFC", "R F C", "TEL", "TELEFONO", "PHONE", "CALLE", "AV", "AVE", "AVENIDA", "BLVD", "COL", "COLONIA", + "C P", "CP", "FOLIO", "TICKET", "CAJA", "CAJERO", "FECHA", "HORA", "DATE", "TIME", "WWW", "COM", + "SUCURSAL", "TIENDA", "STORE", "MESA", "MESERO", "ORDEN", "ORDER", "CLIENTE", "TERMINAL", "GRACIAS", + "THANK", "BIENVENIDO", "BIENVENIDOS", "WELCOME", "NOTA DE VENTA", "COMPROBANTE", "FACTURA", "IVA", + "SUBTOTAL", "TOTAL", "IMPORTE", "EFECTIVO", "CAMBIO", "PROPINA", "TARJETA", "PAGO", "CANT", + "DESCRIPCION", "DESCRIPTION", "PRECIO", "PRICE", "QTY", "ARTICULO", "CODIGO", "SKU", + ] + + /// Column headers ("CANT DESCRIPCION IMPORTE"): a label-less row made of + /// these must not lend its IMPORTE to the item row below. + static let columnHeaderWords: [String] = [ + "CANT", "CANTIDAD", "DESCRIPCION", "DESCRIPTION", "ARTICULO", "ARTICULOS", "PRODUCTO", "CONCEPTO", + "PRECIO", "P UNIT", "PU", "QTY", "ITEM", "ITEMS", "UNIT", "UNIDAD", "CODIGO", "SKU", "DESC", + ] + + /// Legal suffixes trimmed from a merchant name before it is shown. + static let legalSuffixes: [String] = [ + "S A DE C V", "SA DE CV", "S DE RL DE CV", "S DE R L DE C V", "S A P I DE C V", "SAPI DE CV", + "S C", "SC", "A C", "AC", "S A", "SA", "INC", "LLC", "LTD", "CORP", "CO", + ] + + // MARK: Brands + + /// Canonical brand names keyed by the phrase that appears on the + /// receipt (normalized form). Razones sociales map to the store people + /// know. Longer phrases first so "UBER EATS" wins over "UBER". + static let brands: [(phrase: String, name: String)] = [ + ("CADENA COMERCIAL OXXO", "OXXO"), ("OXXO", "OXXO"), + ("NUEVA WAL MART DE MEXICO", "Walmart"), ("WAL MART", "Walmart"), ("WALMART", "Walmart"), + ("BODEGA AURRERA", "Bodega Aurrerá"), ("SAM S CLUB", "Sam's Club"), ("SAMS CLUB", "Sam's Club"), + ("SUPERAMA", "Superama"), + ("TIENDAS SORIANA", "Soriana"), ("SORIANA", "Soriana"), + ("COSTCO WHOLESALE", "Costco"), ("COSTCO", "Costco"), + ("H E B", "H-E-B"), ("HEB", "H-E-B"), + ("TIENDAS CHEDRAUI", "Chedraui"), ("CHEDRAUI", "Chedraui"), + ("CALIMAX", "Calimax"), ("SMART FINAL", "Smart & Final"), ("SMART AND FINAL", "Smart & Final"), + ("CASA LEY", "Casa Ley"), ("LA COMER", "La Comer"), ("CITY MARKET", "City Market"), + ("7 ELEVEN", "7-Eleven"), ("SEVEN ELEVEN", "7-Eleven"), ("CIRCLE K", "Circle K"), + ("STARBUCKS", "Starbucks"), ("TIM HORTONS", "Tim Hortons"), ("PUNTA DEL CIELO", "Punta del Cielo"), + ("CIELITO QUERIDO", "Cielito Querido"), ("DUTCH BROS", "Dutch Bros"), ("PEET S", "Peet's"), + ("UBER EATS", "Uber Eats"), ("UBER", "Uber"), ("DIDI FOOD", "DiDi Food"), ("DIDI", "DiDi"), + ("RAPPI", "Rappi"), ("LYFT", "Lyft"), + ("MCDONALD S", "McDonald's"), ("MCDONALDS", "McDonald's"), ("BURGER KING", "Burger King"), + ("CARL S JR", "Carl's Jr."), ("CARLS JR", "Carl's Jr."), ("KFC", "KFC"), ("SUBWAY", "Subway"), + ("DOMINO S", "Domino's"), ("DOMINOS", "Domino's"), ("LITTLE CAESARS", "Little Caesars"), + ("PIZZA HUT", "Pizza Hut"), ("CHIPOTLE", "Chipotle"), ("IN N OUT", "In-N-Out"), ("VIPS", "Vips"), + ("TOKS", "Toks"), ("SANBORNS", "Sanborns"), ("EL POLLO LOCO", "El Pollo Loco"), + ("PANDA EXPRESS", "Panda Express"), ("TACO BELL", "Taco Bell"), ("WENDY S", "Wendy's"), + ("PEMEX", "Pemex"), ("CHEVRON", "Chevron"), ("SHELL", "Shell"), ("ARCO", "ARCO"), ("MOBIL", "Mobil"), + ("AMAZON", "Amazon"), ("LIVERPOOL", "Liverpool"), ("COPPEL", "Coppel"), ("ELEKTRA", "Elektra"), + ("SUBURBIA", "Suburbia"), ("SEARS", "Sears"), ("ZARA", "Zara"), ("H M", "H&M"), ("TARGET", "Target"), + ("HOME DEPOT", "Home Depot"), ("BEST BUY", "Best Buy"), ("APPLE STORE", "Apple"), ("IKEA", "IKEA"), + ("ROSS DRESS FOR LESS", "Ross"), ("MARSHALLS", "Marshalls"), ("TJ MAXX", "T.J. Maxx"), + ("TRADER JOE S", "Trader Joe's"), ("TRADER JOES", "Trader Joe's"), ("ALDI", "Aldi"), + ("KROGER", "Kroger"), ("VONS", "Vons"), ("RALPHS", "Ralphs"), ("SAFEWAY", "Safeway"), + ("WHOLE FOODS", "Whole Foods"), ("SPROUTS", "Sprouts"), ("FOOD 4 LESS", "Food 4 Less"), + ("FARMACIAS GUADALAJARA", "Farmacias Guadalajara"), ("FARMACIAS SIMILARES", "Farmacias Similares"), + ("FARMACIAS BENAVIDES", "Farmacias Benavides"), ("FARMACIA DEL AHORRO", "Farmacias del Ahorro"), + ("FARMACIAS DEL AHORRO", "Farmacias del Ahorro"), ("FARMACIA SAN PABLO", "Farmacia San Pablo"), + ("CVS", "CVS"), ("WALGREENS", "Walgreens"), ("RITE AID", "Rite Aid"), + ("CINEPOLIS", "Cinépolis"), ("CINEMEX", "Cinemex"), ("AMC", "AMC"), + ("OFFICE DEPOT", "Office Depot"), ("OFFICEMAX", "OfficeMax"), ("STAPLES", "Staples"), + ("TELCEL", "Telcel"), ("AT T", "AT&T"), ("TOTALPLAY", "Totalplay"), ("IZZI", "izzi"), + ] + + // MARK: Category hints + + /// Keyword → chip. Checked against the merchant name and the top rows. + /// More specific phrases (`UBER EATS`) are listed before general ones + /// (`UBER`) — the first hit wins. + static let categoryKeywords: [(phrase: String, category: ExpenseCategory)] = [ + // Food-delivery brands beat their ride-hailing parents. + ("UBER EATS", .food), ("DIDI FOOD", .food), ("RAPPI", .food), + // Coffee + ("STARBUCKS", .coffee), ("TIM HORTONS", .coffee), ("PUNTA DEL CIELO", .coffee), ("CIELITO", .coffee), + ("DUTCH BROS", .coffee), ("PEET S", .coffee), ("CAFE", .coffee), ("CAFETERIA", .coffee), + ("COFFEE", .coffee), ("ESPRESSO", .coffee), ("LATTE", .coffee), ("CAPPUCCINO", .coffee), + ("CAPUCHINO", .coffee), ("BARISTA", .coffee), ("TOSTADOR", .coffee), ("ROASTERS", .coffee), + // Transit + ("UBER", .transit), ("DIDI", .transit), ("LYFT", .transit), ("PEMEX", .transit), + ("GASOLINERA", .transit), ("GASOLINA", .transit), ("GAS STATION", .transit), ("MAGNA", .transit), + ("DIESEL", .transit), ("CHEVRON", .transit), ("SHELL", .transit), + ("ARCO", .transit), ("MOBIL", .transit), ("ESTACIONAMIENTO", .transit), ("PARKING", .transit), + ("PENSION", .transit), ("PEAJE", .transit), ("CASETA", .transit), ("TOLL", .transit), + ("TAXI", .transit), ("METRO", .transit), ("AUTOBUS", .transit), ("CAMION", .transit), + ("BUS", .transit), ("TROLLEY", .transit), ("AMTRAK", .transit), ("SENTRI", .transit), + // Food (restaurants, delivery, groceries, convenience) + ("RESTAURANT", .food), ("RESTAURANTE", .food), ("TAQUERIA", .food), ("TACOS", .food), + ("PIZZA", .food), ("SUSHI", .food), ("BURGER", .food), ("HAMBURGUESA", .food), ("MCDONALD", .food), + ("DOMINO", .food), ("KFC", .food), ("SUBWAY", .food), ("CHIPOTLE", .food), ("VIPS", .food), + ("TOKS", .food), ("SANBORNS", .food), ("MARISCOS", .food), ("COCINA", .food), ("COMIDA", .food), + ("FONDA", .food), ("BIRRIA", .food), ("POLLO", .food), ("CARNITAS", .food), ("PANADERIA", .food), + ("TORTILLERIA", .food), ("ABARROTES", .food), ("SUPER", .food), ("MERCADO", .food), + ("MARKET", .food), ("GROCERY", .food), ("OXXO", .food), ("7 ELEVEN", .food), ("CIRCLE K", .food), + ("WALMART", .food), ("WAL MART", .food), ("BODEGA AURRERA", .food), ("SORIANA", .food), + ("COSTCO", .food), ("HEB", .food), ("H E B", .food), ("CHEDRAUI", .food), ("CALIMAX", .food), + ("SMART FINAL", .food), ("CASA LEY", .food), ("LA COMER", .food), ("TRADER JOE", .food), + ("ALDI", .food), ("KROGER", .food), ("VONS", .food), ("RALPHS", .food), ("SAFEWAY", .food), + ("WHOLE FOODS", .food), ("SPROUTS", .food), ("BAR", .food), ("CANTINA", .food), ("CERVECERIA", .food), + ("PANDA EXPRESS", .food), ("TACO BELL", .food), ("WENDY", .food), ("CARL S JR", .food), + ("IN N OUT", .food), ("EL POLLO LOCO", .food), ("LITTLE CAESARS", .food), ("PIZZA HUT", .food), + // Shopping + ("AMAZON", .shopping), ("LIVERPOOL", .shopping), ("COPPEL", .shopping), ("ELEKTRA", .shopping), + ("SUBURBIA", .shopping), ("SEARS", .shopping), ("ZARA", .shopping), ("H M", .shopping), + ("TARGET", .shopping), ("HOME DEPOT", .shopping), ("BEST BUY", .shopping), ("APPLE STORE", .shopping), + ("IKEA", .shopping), ("ROSS", .shopping), ("MARSHALLS", .shopping), ("TJ MAXX", .shopping), + ("NIKE", .shopping), ("ADIDAS", .shopping), ("FARMACIA", .shopping), ("FARMACIAS", .shopping), + ("PHARMACY", .shopping), ("CVS", .shopping), ("WALGREENS", .shopping), ("RITE AID", .shopping), + ("OFFICE DEPOT", .shopping), ("OFFICEMAX", .shopping), ("STAPLES", .shopping), + ("BOUTIQUE", .shopping), ("MALL", .shopping), + ("CINEPOLIS", .shopping), ("CINEMEX", .shopping), ("AMC", .shopping), + ] + + /// Custom words handed to Vision so label vocabulary is recognised + /// even on faded thermal paper. + static let recognitionCustomWords: [String] = [ + "TOTAL", "SUBTOTAL", "IVA", "IEPS", "EFECTIVO", "CAMBIO", "PROPINA", "IMPORTE", "TARJETA", "PAGO", + "MXN", "USD", "OXXO", "FOLIO", "TICKET", "CAJA", "RFC", "GRACIAS", "ARTICULOS", "DESCUENTO", + ] +} diff --git a/Savely/Utilities/ReceiptOCR.swift b/Savely/Utilities/ReceiptOCR.swift new file mode 100644 index 0000000..d801e83 --- /dev/null +++ b/Savely/Utilities/ReceiptOCR.swift @@ -0,0 +1,184 @@ +// +// ReceiptOCR.swift +// Savely +// +// On-device text recognition for a receipt photo. iOS 26's +// `RecognizeDocumentsRequest` gives us every text line with its box plus +// Apple's own money/date detectors in one pass; `RecognizeTextRequest` +// is the fallback when the document request finds nothing. Nothing here +// touches the network — the receipt never leaves the phone. +// +// Two things the old TextRecognizer got wrong and this file fixes: +// the image ORIENTATION is always passed (a UIImage's cgImage is the raw +// sensor bitmap; without the EXIF orientation Vision reads portrait +// receipts sideways), and the recognition LANGUAGES are Spanish + English +// (resolved at runtime from what the OS supports) instead of the en-US +// default. +// + +import Foundation +import ImageIO +import UIKit +import Vision +import DataDetection + +/// A money amount Apple's data detector found in the recognized text. +struct DetectedMoney: Equatable, Sendable { + var amount: Double + var currency: String +} + +struct ReceiptOCRResult: Sendable { + var lines: [OCRLine] + var detectedMoney: [DetectedMoney] + var detectedDates: [Date] + /// The document title Vision inferred (often the merchant), if any. + var title: String? +} + +enum ReceiptOCRError: Error, Equatable { + case badImage + case noText +} + +enum ReceiptOCR { + /// Longest side handed to Vision. Receipts are text at close range; + /// more pixels than this only cost time. + static let maxDimension: CGFloat = 2200 + + /// Recognizes the receipt in `image`. Runs Vision's work off the caller's + /// actor; safe to call from the main actor with `await`. + static func recognize(_ image: UIImage) async throws -> ReceiptOCRResult { + let orientation = CGImagePropertyOrientation(image.imageOrientation) + return try await Task.detached(priority: .userInitiated) { + // Decoding the 12 MP still and downscaling it are the expensive + // non-Vision steps — keep them off the main actor too. + guard let cgImage = image.cgImage else { throw ReceiptOCRError.badImage } + let working = cgImage.downscaled(maxDimension: maxDimension) ?? cgImage + return try await recognize(cgImage: working, orientation: orientation) + }.value + } + + static func recognize(cgImage: CGImage, orientation: CGImagePropertyOrientation) async throws -> ReceiptOCRResult { + var documentRequest = RecognizeDocumentsRequest() + var options = documentRequest.textRecognitionOptions + options.recognitionLanguages = preferredLanguages(from: documentRequest.supportedRecognitionLanguages) + options.useLanguageCorrection = false + options.customWords = ReceiptVocabulary.recognitionCustomWords + documentRequest.textRecognitionOptions = options + + if let observation = try await documentRequest.perform(on: cgImage, orientation: orientation).first { + let lines = observation.document.text.lines.map(ocrLine(from:)) + if !lines.isEmpty { + return ReceiptOCRResult( + lines: lines, + detectedMoney: detectedMoney(in: observation.document.text.detectedData), + detectedDates: detectedDates(in: observation.document.text.detectedData), + title: observation.document.title?.transcript.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + } + + var textRequest = RecognizeTextRequest() + textRequest.recognitionLevel = .accurate + textRequest.recognitionLanguages = preferredLanguages(from: textRequest.supportedRecognitionLanguages) + textRequest.usesLanguageCorrection = false + textRequest.customWords = ReceiptVocabulary.recognitionCustomWords + let observations = try await textRequest.perform(on: cgImage, orientation: orientation) + let lines = observations.map(ocrLine(from:)) + guard !lines.isEmpty else { throw ReceiptOCRError.noText } + return ReceiptOCRResult(lines: lines, detectedMoney: [], detectedDates: [], title: nil) + } + + // MARK: - Languages + + /// Spanish and English, in the device's preferred order, restricted to + /// what this OS build actually supports. Empty (Vision's default) only if + /// neither is available. + static func preferredLanguages(from supported: [Locale.Language]) -> [Locale.Language] { + let wanted = ["es", "en"] + let deviceFirst = Locale.preferredLanguages.compactMap { Locale(identifier: $0).language.languageCode?.identifier } + let order = (deviceFirst + wanted).filter { wanted.contains($0) }.reduce(into: [String]()) { acc, code in + if !acc.contains(code) { acc.append(code) } + } + return order.compactMap { code in + supported.first { $0.languageCode?.identifier == code } + } + } + + // MARK: - Conversion + + /// Vision boxes have a bottom-left origin; OCRLine uses top-left so that + /// "above" is a smaller y for the parser. + static func ocrLine(from observation: RecognizedTextObservation) -> OCRLine { + let candidate = observation.topCandidates(1).first + let rect = observation.boundingBox.cgRect + let box = OCRBox( + x: Double(rect.origin.x), + y: Double(1 - rect.origin.y - rect.size.height), + width: Double(rect.size.width), + height: Double(rect.size.height) + ) + return OCRLine(text: candidate?.string ?? observation.transcript, box: box, confidence: Double(candidate?.confidence ?? observation.confidence)) + } + + static func detectedMoney(in matches: [DocumentObservation.Container.DataDetectorMatch]) -> [DetectedMoney] { + matches.compactMap { match in + if case .moneyAmount(let money) = match.match.details { + let rounded = (NSDecimalNumber(decimal: money.amount).doubleValue * 100).rounded() / 100 + return DetectedMoney(amount: rounded, currency: money.currency.identifier) + } + return nil + } + } + + static func detectedDates(in matches: [DocumentObservation.Container.DataDetectorMatch]) -> [Date] { + matches.compactMap { match in + if case .calendarEvent(let event) = match.match.details { return event.startDate } + return nil + } + } +} + +// MARK: - Orientation + +extension CGImagePropertyOrientation { + /// UIImage and ImageIO number their orientations differently; this is + /// Apple's documented name-for-name mapping. + init(_ orientation: UIImage.Orientation) { + switch orientation { + case .up: self = .up + case .upMirrored: self = .upMirrored + case .down: self = .down + case .downMirrored: self = .downMirrored + case .left: self = .left + case .leftMirrored: self = .leftMirrored + case .right: self = .right + case .rightMirrored: self = .rightMirrored + @unknown default: self = .up + } + } +} + +// MARK: - Downscale + +extension CGImage { + /// A copy no larger than `maxDimension` on its longest side, or nil when + /// the image is already small enough (or drawing fails). Pixel data + /// keeps its sensor orientation; pass the same orientation to Vision. + func downscaled(maxDimension: CGFloat) -> CGImage? { + let longest = CGFloat(max(width, height)) + guard longest > maxDimension else { return nil } + let scale = maxDimension / longest + let newWidth = Int((CGFloat(width) * scale).rounded()) + let newHeight = Int((CGFloat(height) * scale).rounded()) + guard let context = CGContext( + data: nil, width: newWidth, height: newHeight, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + context.interpolationQuality = .high + context.draw(self, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight)) + return context.makeImage() + } +} diff --git a/Savely/ViewModels/Camera/ReceiptScanModel.swift b/Savely/ViewModels/Camera/ReceiptScanModel.swift new file mode 100644 index 0000000..d712d07 --- /dev/null +++ b/Savely/ViewModels/Camera/ReceiptScanModel.swift @@ -0,0 +1,273 @@ +// +// ReceiptScanModel.swift +// Savely +// +// State for the receipt scan flow: capture → reading → review → saved. +// Owned by whoever presents the scanner (the "+" sheet or the Money tab) +// and handed to ReceiptScanFlowView. Everything the user sees on the +// review screen is a draft they can edit; nothing is saved until they tap +// Save, and a save that fails keeps the screen open. +// + +import Foundation +import Observation +import UIKit + +/// The editable draft shown on the review screen. +struct ReceiptReview { + var image: UIImage + /// Keypad string ("41.18", "250"); "0" when nothing was read. + var amountText: String + /// Other amounts read from the receipt, best first. + var alternatives: [Double] + var merchant: String + var date: Date + /// True when `date` was read from the receipt (else it is "now"). + var dateWasRead: Bool + var category: ExpenseCategory + var confidence: ReceiptConfidence + var currencyHint: String? + /// What OCR saw — for the DEBUG dump only. + var ocrLines: [OCRLine] + + var amountValue: Double? { parseAmount(amountText) } +} + +@MainActor +@Observable +final class ReceiptScanModel: Identifiable { + enum Phase: Hashable { + case capturing + case processing + case review + case failed(String) + } + + let id = UUID() + let camera = CameraManager() + private(set) var phase: Phase = .capturing + var review: ReceiptReview? + /// Set by the flow view; called when the flow is over (saved or cancelled). + @ObservationIgnored var onFinished: (() -> Void)? + + @ObservationIgnored private let expenseStore: ExpenseTrackerViewModel + @ObservationIgnored private var scanTask: Task? + @ObservationIgnored private let now: () -> Date + + /// The error message to show over the review screen when saving fails. + var saveErrorMessage: String? + + init(expenseStore: ExpenseTrackerViewModel, now: @escaping () -> Date = Date.init) { + self.expenseStore = expenseStore + self.now = now + } + + var isProcessing: Bool { phase == .processing } + + // MARK: - Capture + + /// Shutter tap: one capture at a time; ignored while reading. + func capture() { + guard phase == .capturing, scanTask == nil else { return } + phase = .processing + scanTask = Task { [weak self] in + guard let self else { return } + do { + let data = try await camera.capturePhoto() + guard let image = UIImage(data: data) else { throw ReceiptOCRError.badImage } + await process(image) + } catch CameraError.notReady { + phase = .failed(Strings.Camera.cameraStartingBody) + } catch { + phase = .failed(Strings.Camera.readFailedBody) + } + scanTask = nil + } + } + + /// The picker closed and the photo is loading (iCloud originals can take + /// seconds): show the reading state right away instead of an idle camera. + func beginImport() { + guard phase == .capturing, scanTask == nil else { return } + phase = .processing + } + + /// A photo picked from the library or returned by the system document + /// camera. Same pipeline as the shutter. + func importImage(_ image: UIImage) { + guard scanTask == nil else { return } + phase = .processing + scanTask = Task { [weak self] in + guard let self else { return } + await process(image) + scanTask = nil + } + } + + /// The photo could not be loaded/decoded, or the system document camera + /// reported an error. + func importFailed() { + guard scanTask == nil else { return } + phase = .failed(Strings.Camera.importFailedBody) + } + + private func process(_ image: UIImage) async { + // The photo is taken; the review screen does not need the live feed + // (or the torch) running behind it. + camera.stopSession() + var draft = ReceiptDraft.empty + var ocr: ReceiptOCRResult? + do { + let result = try await ReceiptOCR.recognize(image) + ocr = result + draft = ReceiptParser.parse(lines: result.lines, now: now()) + } catch { + // No text at all is the honest "we couldn't read it" case; the + // user can still enter the amount by hand from the review screen. + print("Receipt OCR failed: \(error)") + } + guard !Task.isCancelled else { return } + review = makeReview(image: image, draft: draft, ocr: ocr) + phase = .review + } + + private func makeReview(image: UIImage, draft: ReceiptDraft, ocr: ReceiptOCRResult?) -> ReceiptReview { + let today = now() + // Apple's detectors are a second opinion, held to the parser's own + // plausibility rules: amounts within range, dates not in the future + // and not older than the parser accepts. + let detectedAmounts = (ocr?.detectedMoney ?? []) + .map(\.amount) + .filter { $0 > 0 && $0 <= ReceiptParser.maxPlausibleTotal } + let detectedDate = (ocr?.detectedDates ?? []).first { ReceiptDateParser.isPlausible($0, now: today) } + + var alternatives = draft.alternatives.map(\.value) + var amountText = draft.total.map { Self.keypadString(for: $0.value) } ?? "0" + if draft.total == nil, let best = detectedAmounts.max() { + amountText = Self.keypadString(for: best) + alternatives = detectedAmounts.filter { !ReceiptParser.approximatelyEqual($0, best) } + } + alternatives = Self.distinct(alternatives) + let date = draft.date ?? detectedDate + return ReceiptReview( + image: image, + amountText: amountText, + alternatives: Array(alternatives.prefix(ReceiptParser.maxAlternatives)), + merchant: draft.merchant ?? ocr?.title ?? "", + date: date ?? today, + dateWasRead: date != nil, + category: draft.categoryHint ?? .other, + confidence: draft.confidence, + currencyHint: draft.currencyHint, + ocrLines: ocr?.lines ?? [] + ) + } + + private static func distinct(_ values: [Double]) -> [Double] { + values.reduce(into: [Double]()) { acc, value in + if !acc.contains(where: { ReceiptParser.approximatelyEqual($0, value) }) { acc.append(value) } + } + } + + // MARK: - Review actions + + func retake() { + // `review` is left in place until the next capture replaces it: the + // review screen's bindings are still alive during its exit animation. + saveErrorMessage = nil + phase = .capturing + camera.startSession() + } + + func pickAlternative(_ value: Double) { + guard var current = review else { return } + let previous = current.amountValue + current.amountText = Self.keypadString(for: value) + current.alternatives.removeAll { ReceiptParser.approximatelyEqual($0, value) } + if let previous, previous > 0, !current.alternatives.contains(where: { ReceiptParser.approximatelyEqual($0, previous) }) { + current.alternatives.insert(previous, at: 0) + } + review = current + } + + /// Saves the reviewed expense. Returns true when the row was written; + /// on false the screen stays open and `saveErrorMessage` explains. + @discardableResult + func save() -> Bool { + guard let review, let amount = review.amountValue else { + saveErrorMessage = Strings.Camera.enterAmountError + return false + } + let merchant = review.merchant.trimmingCharacters(in: .whitespacesAndNewlines) + let description = merchant.isEmpty ? Strings.Camera.scannedReceiptDescription : merchant + let saved = expenseStore.addExpense( + description: description, + amount: amount, + date: review.date, + category: review.category.rawValue + ) + guard saved else { + saveErrorMessage = expenseStore.errorMessage + return false + } + finish() + return true + } + + func cancel() { + scanTask?.cancel() + finish() + } + + private func finish() { + camera.stopSession() + onFinished?() + } + + // MARK: - Helpers + + /// "41.18" for 41.18, "250" for 250 — what WarmKeypad edits. + static func keypadString(for value: Double) -> String { + guard value > 0 else { return "0" } + let cents = (value * 100).rounded() + if cents.truncatingRemainder(dividingBy: 100) == 0 { + return String(Int(cents / 100)) + } + return String(format: "%.2f", cents / 100) + } + + #if DEBUG + /// Writes what OCR saw as a JSON fixture (same shape as + /// SavelyTests/Fixtures/Receipts) so a misread receipt can become a + /// regression test. Strip names/RFC/phone/card lines before committing. + func exportOCRDump() -> URL? { + guard let review else { return nil } + struct Dump: Encodable { + var id: String + var source: String + var locale: String + var now: String + var lines: [OCRLine] + var expected: [String: Double] + } + let dump = Dump( + id: "device-\(Int(Date().timeIntervalSince1970))", + source: "device OCR dump — REVIEW AND ANONYMIZE before committing", + locale: Locale.current.identifier, + now: ISO8601DateFormatter().string(from: Date()), + lines: review.ocrLines, + expected: ["total": review.amountValue ?? 0] + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(dump) else { return nil } + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(dump.id).json") + do { + try data.write(to: url, options: .atomic) + return url + } catch { + return nil + } + } + #endif +} diff --git a/Savely/ViewModels/ExpensesTab/ExpensesTrackerViewModel.swift b/Savely/ViewModels/ExpensesTab/ExpensesTrackerViewModel.swift index 3175add..d93c639 100644 --- a/Savely/ViewModels/ExpensesTab/ExpensesTrackerViewModel.swift +++ b/Savely/ViewModels/ExpensesTab/ExpensesTrackerViewModel.swift @@ -64,21 +64,44 @@ class ExpenseTrackerViewModel: ObservableObject { } /// - Parameter category: the chip picked in the quick-add sheet. The - /// inline Money-tab form and the receipt scanner pass nothing — their - /// rows display through keyword inference instead. + /// inline Money-tab form passes nothing — its rows display through + /// keyword inference instead. (The receipt scanner saves through + /// `addExpense(description:amount:date:category:)` below.) func addExpense(category: String? = nil) { - guard let modelContext = modelContext else { return } guard let amountValue = parseAmount(amount) else { // Used to return silently, so a typo just did nothing at all. - errorMessage = "Enter a valid amount greater than zero." + errorMessage = Strings.Errors.invalidAmount showError = true return } + if !addExpense(description: expenseDescription, amount: amountValue, date: Date(), category: category) { + showError = true + } + expenseDescription = "" + amount = "" + } + + /// Saves one expense with every field given explicitly — the receipt + /// scanner's path, which does not go through the form fields above. + /// Returns false and sets `errorMessage` when nothing was saved, so the + /// caller can keep its screen open instead of dismissing over a silent + /// failure. Does NOT flip `showError` — that alert belongs to the inline + /// form (`addExpense(category:)`); the scanner shows the message itself. + @discardableResult + func addExpense(description: String, amount amountValue: Double, date: Date, category: String?) -> Bool { + guard let modelContext = modelContext else { + errorMessage = Strings.Errors.saveExpenseFailed + return false + } + guard amountValue > 0 else { + errorMessage = Strings.Errors.invalidAmount + return false + } let newExpense = ExpenseModel( - expenseDescription: expenseDescription, + expenseDescription: description, amount: amountValue, - date: Date(), + date: date, category: category ) modelContext.insert(newExpense) @@ -86,17 +109,16 @@ class ExpenseTrackerViewModel: ObservableObject { do { try modelContext.save() print("New expense saved successfully") - NotificationCenter.default.post(name: .expenseAdded, object: nil, userInfo: ["amount": amountValue]) } catch { print("Error saving new expense: \(error)") - errorMessage = "Error al guardar el gasto." - showError = true + errorMessage = Strings.Errors.saveExpenseFailed + fetchExpenses() + return false } - expenseDescription = "" - amount = "" fetchExpenses() + return true } func deleteExpense(_ expense: ExpenseModel) { diff --git a/Savely/Views/Components/CameraScanner/CameraView.swift b/Savely/Views/Components/CameraScanner/CameraView.swift index e39a916..abb0419 100755 --- a/Savely/Views/Components/CameraScanner/CameraView.swift +++ b/Savely/Views/Components/CameraScanner/CameraView.swift @@ -4,97 +4,196 @@ // // Created by Ivan Lorenzana Belli on 21/10/24. // +// The custom Warm Meadow capture surface: live preview, a guide frame, +// torch, gallery import and the shutter. State lives in ReceiptScanModel; +// processing/failure/review overlays are drawn by ReceiptScanFlowView on +// top of this view. +// import SwiftUI -import Vision -import UIKit +import PhotosUI struct CameraView: View { - @ObservedObject var viewModel: CameraViewModel - @Environment(\.presentationMode) var presentationMode + @Bindable var model: ReceiptScanModel + @State private var pickedItem: PhotosPickerItem? + @Environment(\.scenePhase) private var scenePhase + + /// Scrim behind white text/glyphs drawn over the live feed — the feed is + /// usually a white receipt on a table, so 35 % black is not enough for + /// legible white text; this keeps the contrast ≥ 4.5:1 on any subject. + private let overlayScrim = Color.black.opacity(0.6) // deliberate: over the camera feed + + private var camera: CameraManager { model.camera } var body: some View { ZStack { - CameraPreview(cameraManager: viewModel.cameraManager) - .edgesIgnoringSafeArea(.all) - - // No back camera (simulator) or access denied — say so instead of - // leaving a frozen black preview. - if !viewModel.isCameraAvailable { - VStack(spacing: 10) { - Image(systemName: "camera.badge.ellipsis") - .warmFont(36) - .foregroundStyle(.white.opacity(0.85)) - Text("Camera not available") - .warmFont(17, weight: .semibold) - .foregroundStyle(.white) - Text("Allow camera access in Settings, or use a device with a camera.") - .warmFont(13) - .foregroundStyle(.white.opacity(0.75)) - .multilineTextAlignment(.center) - } - .padding(24) - .frame(maxWidth: 300) - .background(Color.black.opacity(0.55)) - .cornerRadius(20) - .accessibilityElement(children: .combine) + CameraPreview(cameraManager: camera) + .ignoresSafeArea() + + // Guide frame: where the receipt should sit. Decorative only. + GuideFrame() + .padding(.horizontal, 28) + .padding(.top, 84) + .padding(.bottom, 190) + .accessibilityHidden(true) + + if !camera.isCameraAvailable { + unavailableCard } - VStack { - HStack { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Image(systemName: "xmark.circle.fill") - .resizable() - .frame(width: 40, height: 40) - .foregroundColor(.white) // deliberate: drawn over the live camera feed - .padding() - } - Spacer() - } + VStack(spacing: 0) { + topBar Spacer() - HStack { - Spacer() - Button(action: { - viewModel.capturePhoto() - }) { - Image(systemName: "camera.circle.fill") - .resizable() - .frame(width: 70, height: 70) - .foregroundColor(.white) // deliberate: drawn over the live camera feed + bottomBar + } + } + .onAppear { camera.startSession() } + .onDisappear { camera.stopSession() } + .onChange(of: scenePhase) { _, phase in + // Back from Settings after granting camera access: try again. + if phase == .active, !camera.isCameraAvailable { camera.startSession() } + } + .onChange(of: pickedItem) { _, item in + guard let item else { return } + model.beginImport() + Task { + defer { pickedItem = nil } + do { + if let data = try await item.loadTransferable(type: Data.self), let image = UIImage(data: data) { + model.importImage(image) + return } - .padding(.bottom, 30) - Spacer() + print("Photo import: no decodable image data") + } catch { + print("Photo import failed: \(error)") } + model.importFailed() } + } + } - // Mostrar la vista de confirmación si se detecta un total - if viewModel.showConfirmation { - TotalConfirmationView( - detectedTotal: viewModel.detectedTotal, - alternativeTotals: viewModel.alternativeTotals, - onConfirm: { total in - viewModel.confirmTotal(total) - }, - onRetake: { - viewModel.retakePhoto() - } - ) - .frame(width: 300, height: 400) - .background(Color.warmSurface) - .cornerRadius(UIConstants.UICornerRadius.cornerRadiusMedium) + // MARK: - Chrome + + private var topBar: some View { + HStack { + Button(action: { model.cancel() }) { + Image(systemName: "xmark") + .warmFont(15, weight: .semibold) + .foregroundStyle(.white) // deliberate: drawn over the live camera feed + .frame(width: 40, height: 40) + .background(overlayScrim) + .clipShape(Circle()) + .tappable44() + } + .accessibilityLabel(Strings.Camera.closeLabel) + Spacer() + if camera.hasTorch { + Button(action: { camera.toggleTorch() }) { + Image(systemName: camera.isTorchOn ? "bolt.fill" : "bolt.slash") + .warmFont(15, weight: .semibold) + .foregroundStyle(.white) // deliberate: over the camera feed + .frame(width: 40, height: 40) + .background(overlayScrim) + .clipShape(Circle()) + .tappable44() + } + .accessibilityLabel(camera.isTorchOn ? Strings.Camera.torchOffLabel : Strings.Camera.torchOnLabel) } } - .onAppear { - viewModel.startSession() - // Asignar el closure para dismiss - viewModel.dismissCameraView = { - presentationMode.wrappedValue.dismiss() + .padding(.horizontal, 20) + .padding(.top, 12) + } + + private var bottomBar: some View { + VStack(spacing: 14) { + Text(Strings.Camera.guideHint) + .warmFont(13, weight: .medium) + .foregroundStyle(.white) // deliberate: over the camera feed + .multilineTextAlignment(.center) + .padding(.horizontal, 14).padding(.vertical, 8) + .background(overlayScrim) + .clipShape(Capsule()) + .padding(.horizontal, 32) + + HStack(alignment: .center) { + PhotosPicker(selection: $pickedItem, matching: .images, photoLibrary: .shared()) { + Image(systemName: "photo.on.rectangle") + .warmFont(20) + .foregroundStyle(.white) // deliberate: over the camera feed + .frame(width: 52, height: 52) + .background(overlayScrim) + .clipShape(Circle()) + } + .accessibilityLabel(Strings.Camera.importFromPhotosLabel) + .frame(maxWidth: .infinity) + + Button(action: { model.capture() }) { + ZStack { + Circle().stroke(Color.white, lineWidth: 4).frame(width: 76, height: 76) // deliberate: shutter over the camera feed + Circle().fill(Color.white).frame(width: 62, height: 62) // deliberate: shutter over the camera feed + } + } + .disabled(!camera.isCameraAvailable || !camera.isRunning || model.isProcessing) + .opacity(camera.isCameraAvailable && camera.isRunning ? 1 : 0.35) + .accessibilityLabel(Strings.Camera.shutterLabel) + .frame(maxWidth: .infinity) + + Color.clear.frame(width: 52, height: 52).frame(maxWidth: .infinity) } + .padding(.horizontal, 24) + + Text(Strings.Camera.privacyNote) + .warmFont(11) + .foregroundStyle(.white) // deliberate: over the camera feed + .multilineTextAlignment(.center) + .padding(.horizontal, 12).padding(.vertical, 6) + .background(overlayScrim) + .clipShape(Capsule()) + .padding(.horizontal, 32) } - .onDisappear { - viewModel.stopSession() + .padding(.bottom, 24) + } + + /// No live feed behind this card, so it uses the Warm Meadow tokens. + private var unavailableCard: some View { + VStack(spacing: 10) { + Image(systemName: "camera.badge.ellipsis") + .warmFont(36) + .foregroundStyle(Color.warmInkMuted) + .accessibilityHidden(true) + Text(Strings.Camera.cameraUnavailableTitle) + .warmFont(17, weight: .semibold) + .foregroundStyle(Color.warmInk) + Text(Strings.Camera.cameraUnavailableBody) + .warmFont(13) + .foregroundStyle(Color.warmInkSoft) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + if let url = URL(string: UIApplication.openSettingsURLString) { + Link(Strings.Camera.openSettingsLabel, destination: url) + .warmFont(13, weight: .semibold) + .foregroundStyle(Color.warmOnGreen) + .padding(.horizontal, 16).padding(.vertical, 10) + .frame(minHeight: 44) + .background(Color.warmGreenFill) + .clipShape(Capsule()) + .padding(.top, 4) + } } + .padding(24) + .frame(maxWidth: 300) + .background(Color.warmSurface) + .cornerRadius(20) + .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.warmLine, lineWidth: 1)) + .accessibilityElement(children: .contain) + } +} + +/// A rounded rectangle with corner ticks — the "put the receipt here" hint. +private struct GuideFrame: View { + var body: some View { + RoundedRectangle(cornerRadius: 18) + .strokeBorder(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.5, dash: [8, 6])) // deliberate: over the camera feed + .allowsHitTesting(false) } } diff --git a/Savely/Views/Components/CameraScanner/DocumentCameraView.swift b/Savely/Views/Components/CameraScanner/DocumentCameraView.swift new file mode 100644 index 0000000..ec7245a --- /dev/null +++ b/Savely/Views/Components/CameraScanner/DocumentCameraView.swift @@ -0,0 +1,60 @@ +// +// DocumentCameraView.swift +// Savely +// +// Apple's document scanner (edge detection, auto-capture, perspective +// correction) wrapped for SwiftUI. Behind FeatureFlags.useSystemDocumentCamera +// so it can be A/B'd against the Warm Meadow camera on a real device — +// the Simulator reports `isSupported == false`. Only the first page is +// used; the image comes back upright. +// + +import SwiftUI +import VisionKit + +struct DocumentCameraView: UIViewControllerRepresentable { + let onScan: (UIImage) -> Void + let onCancel: () -> Void + let onError: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onScan: onScan, onCancel: onCancel, onError: onError) + } + + func makeUIViewController(context: Context) -> VNDocumentCameraViewController { + let controller = VNDocumentCameraViewController() + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {} + + final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate { + let onScan: (UIImage) -> Void + let onCancel: () -> Void + let onError: () -> Void + + init(onScan: @escaping (UIImage) -> Void, onCancel: @escaping () -> Void, onError: @escaping () -> Void) { + self.onScan = onScan + self.onCancel = onCancel + self.onError = onError + } + + func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) { + guard scan.pageCount > 0 else { + onCancel() + return + } + onScan(scan.imageOfPage(at: 0)) + } + + func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) { + onCancel() + } + + func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: Error) { + print("Document camera failed: \(error)") + onError() + } + } +} diff --git a/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift b/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift new file mode 100644 index 0000000..f7fe31d --- /dev/null +++ b/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift @@ -0,0 +1,291 @@ +// +// ReceiptReviewView.swift +// Savely +// +// The "we read this — is it right?" screen. Everything on it is editable: +// the amount (keypad), the other amounts read from the receipt (chips), +// the merchant, the date and the category chip. Nothing is saved until +// the user taps Save; a failed save keeps the screen open and says why. +// + +import SwiftUI + +struct ReceiptReviewView: View { + @Bindable var model: ReceiptScanModel + + private var review: Binding? { + Binding($model.review) + } + + var body: some View { + if let review { + content(review) + } + } + + @ViewBuilder + private func content(_ review: Binding) -> some View { + VStack(spacing: 0) { + header + + ScrollView { + VStack(spacing: 0) { + lead(review.wrappedValue) + amount(review) + if !review.wrappedValue.alternatives.isEmpty { + alternatives(review.wrappedValue) + } + merchantField(review) + dateRow(review) + categoryChips(review) + if let message = model.saveErrorMessage { + Text(message) + .warmFont(13, weight: .medium) + .foregroundStyle(Color.warmClay) + .multilineTextAlignment(.center) + .padding(.horizontal, 24).padding(.top, 12) + .accessibilityAddTraits(.updatesFrequently) + } + #if DEBUG + debugExport + #endif + Spacer(minLength: 12) + } + } + .scrollDismissesKeyboard(.interactively) + + WarmKeypad(amountStr: review.amountText) + } + .background(Color.warmBg) + } + + // MARK: - Pieces + + /// Retake · title · Save on one row; at accessibility sizes the title + /// drops under the buttons instead of truncating. + private var header: some View { + ViewThatFits(in: .horizontal) { + HStack { + retakeButton + Spacer() + title + Spacer() + saveButton + } + VStack(spacing: 6) { + HStack { + retakeButton + Spacer() + saveButton + } + title + } + } + .padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 6) + } + + private var title: some View { + Text(Strings.Camera.reviewTitle) + .warmFont(18, weight: .regular, design: .serif) + .foregroundStyle(Color.warmInk) + .accessibilityAddTraits(.isHeader) + } + + private var retakeButton: some View { + Button(action: { model.retake() }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left").warmFont(13, weight: .semibold) + Text(Strings.Camera.retakeLabel).warmFont(13, weight: .semibold) + } + .foregroundStyle(Color.warmInkSoft) + .tappable44() + } + } + + private var saveButton: some View { + Button(action: { model.save() }) { + Text(Strings.Camera.saveExpenseLabel) + .warmFont(13, weight: .semibold) + .foregroundStyle(Color.warmOnGreen) + .padding(.horizontal, 14).padding(.vertical, 8) + .frame(minHeight: 44) + .background(Color.warmGreenFill) + .clipShape(Capsule()) + } + .disabled(!canSave) + .opacity(canSave ? 1 : 0.4) + } + + private var canSave: Bool { (model.review?.amountValue ?? 0) > 0 } + + private func lead(_ review: ReceiptReview) -> some View { + HStack(alignment: .top, spacing: 14) { + Image(uiImage: review.image) + .resizable() + .scaledToFill() + .frame(width: 56, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.warmLine, lineWidth: 1)) + .accessibilityLabel(Strings.Camera.receiptThumbnailLabel) + VStack(alignment: .leading, spacing: 4) { + Text(leadCopy(review)) + .warmFont(15, weight: .regular, design: .serif) + .foregroundStyle(Color.warmInk) + .fixedSize(horizontal: false, vertical: true) + Text(Strings.Camera.privacyNote) + .warmFont(11) + .foregroundStyle(Color.warmInkMuted) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 20).padding(.top, 8) + } + + private func leadCopy(_ review: ReceiptReview) -> String { + switch review.confidence { + case .high, .medium: return Strings.Camera.reviewLeadRead + case .low: return Strings.Camera.reviewLeadUnsure + case .none: return review.amountValue == nil ? Strings.Camera.reviewLeadNone : Strings.Camera.reviewLeadUnsure + } + } + + private func amount(_ review: Binding) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text("−$") + .warmFont(28, weight: .regular, design: .serif).foregroundStyle(Color.warmInkMuted) + Text(formatKeypadAmount(review.wrappedValue.amountText)) + .warmFont(60, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) + .monospacedDigit().minimumScaleFactor(0.4).lineLimit(1) + if let currency = review.wrappedValue.currencyHint { + Text(currency) + .warmFont(12, weight: .semibold) + .foregroundStyle(Color.warmInkMuted) + .padding(.leading, 4) + } + } + .padding(.top, 14) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(Strings.Camera.amountAccessibilityLabel)) + .accessibilityValue(Text(review.wrappedValue.amountText)) + } + + private func alternatives(_ review: ReceiptReview) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(Strings.Camera.alternativesLabel) + .warmFont(11, weight: .semibold) + .foregroundStyle(Color.warmInkMuted) + .textCase(.uppercase) + .padding(.horizontal, 20) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(review.alternatives, id: \.self) { value in + Button(action: { model.pickAlternative(value) }) { + Text(formatKeypadAmount(ReceiptScanModel.keypadString(for: value))) + .warmFont(13, weight: .semibold) + .monospacedDigit() + .foregroundStyle(Color.warmInk) + .padding(.horizontal, 14).padding(.vertical, 8) + .background(Color.warmSurface) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color.warmLine, lineWidth: 1)) + .tappable44() + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + } + } + .padding(.top, 14) + } + + private func merchantField(_ review: Binding) -> some View { + TextField(Strings.Camera.merchantPlaceholder, text: review.merchant) + .warmFont(13).foregroundStyle(Color.warmInk) + .multilineTextAlignment(.center) + .textInputAutocapitalization(.words) + .padding(.horizontal, 16).padding(.vertical, 8) + .background(Color.warmSurface).clipShape(Capsule()) + .overlay(Capsule().stroke(Color.warmLine, lineWidth: 1)) + .padding(.horizontal, 48).padding(.top, 16) + } + + private func dateRow(_ review: Binding) -> some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(Strings.Camera.dateLabel) + .warmFont(13, weight: .semibold) + .foregroundStyle(Color.warmInk) + Text(review.wrappedValue.dateWasRead ? Strings.Camera.dateFromReceiptNote : Strings.Camera.dateTodayNote) + .warmFont(11) + .foregroundStyle(Color.warmInkMuted) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + DatePicker("", selection: review.date, in: ...Date(), displayedComponents: .date) + .labelsHidden() + .tint(Color.warmGreen) + .accessibilityLabel(Strings.Camera.dateLabel) + } + .padding(.horizontal, 20).padding(.top, 14) + } + + private func categoryChips(_ review: Binding) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ExpenseCategory.allCases) { cat in + let isOn = review.wrappedValue.category == cat + let fg = cat == .other ? Color.warmInkSoft : cat.tileColor + Button(action: { review.wrappedValue.category = cat }) { + Text(cat.label) + .warmFont(13, weight: .semibold) + .foregroundStyle(isOn ? fg : Color.warmInkSoft) + .padding(.horizontal, 14).padding(.vertical, 8) + .background(isOn ? cat.tileBackground : Color.clear) + .clipShape(Capsule()) + .overlay(Capsule().stroke(isOn ? cat.tileBackground : Color.warmLine, lineWidth: 1)) + .tappable44() + } + .buttonStyle(.plain) + .accessibilityAddTraits(isOn ? [.isSelected] : []) + } + } + .padding(.horizontal, 20) + } + .padding(.top, 14) + } + + #if DEBUG + /// Debug builds only: share what OCR read as a JSON fixture. The file is + /// written when the button is tapped, not on every render. + private var debugExport: some View { + DebugDumpButton(model: model) + } + #endif +} + +#if DEBUG +private struct DebugDumpButton: View { + let model: ReceiptScanModel + @State private var dumpURL: URL? + + var body: some View { + Group { + if let dumpURL { + ShareLink(item: dumpURL) { + Label(Strings.Camera.exportDumpLabel, systemImage: "square.and.arrow.up") + } + } else { + Button(action: { dumpURL = model.exportOCRDump() }) { + Label(Strings.Camera.exportDumpLabel, systemImage: "doc.text") + } + } + } + .warmFont(11) + .foregroundStyle(Color.warmInkMuted) + .padding(.top, 14) + } +} +#endif diff --git a/Savely/Views/Components/CameraScanner/ReceiptScanFlowView.swift b/Savely/Views/Components/CameraScanner/ReceiptScanFlowView.swift new file mode 100644 index 0000000..024e1c1 --- /dev/null +++ b/Savely/Views/Components/CameraScanner/ReceiptScanFlowView.swift @@ -0,0 +1,162 @@ +// +// ReceiptScanFlowView.swift +// Savely +// +// The whole "Scan a receipt" flow in one presented view: capture (our +// Warm Meadow camera, or Apple's document camera behind +// FeatureFlags.useSystemDocumentCamera) → a visible "reading" state → +// the review screen → saved. Presented full-screen from the "+" sheet and +// from the Money tab; both hand it a ReceiptScanModel they own. +// + +import SwiftUI +import VisionKit + +struct ReceiptScanFlowView: View { + @Bindable var model: ReceiptScanModel + @Environment(\.dismiss) private var dismiss + @AccessibilityFocusState private var focusedOverlay: ReceiptScanModel.Phase? + + private var usesSystemCamera: Bool { + FeatureFlags.useSystemDocumentCamera && VNDocumentCameraViewController.isSupported + } + + var body: some View { + ZStack { + captureLayer + // The camera stays mounted under the overlays (so Retake is + // instant), but its controls must not be reachable by + // VoiceOver while another state is on top. + .accessibilityHidden(model.phase != .capturing) + overlay + } + .background(Color.black) + .honorsReduceMotion() + .animation(.easeInOut(duration: 0.2), value: model.phase) + .onAppear { model.onFinished = { dismiss() } } + .onChange(of: model.phase) { _, phase in + announce(phase) + } + .statusBarHidden(model.phase == .capturing && !usesSystemCamera) + } + + /// VoiceOver hears each state change and lands on the new screen. + private func announce(_ phase: ReceiptScanModel.Phase) { + let text: String? + switch phase { + case .capturing: text = nil + case .processing: text = Strings.Camera.processingLabel + case .review: text = Strings.Camera.reviewTitle + case .failed: text = Strings.Camera.readFailedTitle + } + guard let text else { return } + AccessibilityNotification.Announcement(text).post() + focusedOverlay = phase + } + + @ViewBuilder + private var captureLayer: some View { + if usesSystemCamera { + if model.phase == .capturing { + DocumentCameraView( + onScan: { image in model.importImage(image) }, + onCancel: { model.cancel() }, + onError: { model.importFailed() } + ) + .ignoresSafeArea() + } else { + Color.warmBg.ignoresSafeArea() + } + } else { + CameraView(model: model) + } + } + + @ViewBuilder + private var overlay: some View { + switch model.phase { + case .capturing: + EmptyView() + case .processing: + processing + case .failed(let message): + failed(message) + case .review: + ReceiptReviewView(model: model) + .transition(.opacity) + .accessibilityAddTraits(.isModal) + .accessibilityFocused($focusedOverlay, equals: .review) + } + } + + private var processing: some View { + VStack(spacing: 14) { + ProgressView() + .tint(.white) // deliberate: over the dimmed camera feed + Text(Strings.Camera.processingLabel) + .warmFont(15, weight: .medium) + .foregroundStyle(.white) // deliberate: over the dimmed camera feed + Button(action: { model.cancel() }) { + Text(Strings.Buttons.cancelButton) + .warmFont(13, weight: .semibold) + .foregroundStyle(.white) // deliberate: over the dimmed camera feed + .frame(minHeight: 44) + .padding(.horizontal, 12) + } + } + .padding(28) + .background(Color.black.opacity(0.65)) // deliberate: over the dimmed camera feed + .cornerRadius(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.25).ignoresSafeArea()) // deliberate: dims the camera feed + .accessibilityAddTraits(.isModal) + .accessibilityFocused($focusedOverlay, equals: .processing) + } + + private func failed(_ message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "doc.text.magnifyingglass") + .warmFont(32) + .foregroundStyle(Color.warmInkMuted) + Text(Strings.Camera.readFailedTitle) + .warmFont(20, weight: .regular, design: .serif) + .foregroundStyle(Color.warmInk) + .multilineTextAlignment(.center) + Text(message) + .warmFont(13) + .foregroundStyle(Color.warmInkSoft) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 10) { + Button(action: { model.retake() }) { + Text(Strings.Camera.retryLabel) + .warmFont(13, weight: .semibold) + .foregroundStyle(Color.warmOnGreen) + .padding(.horizontal, 16).padding(.vertical, 10) + .frame(minHeight: 44) + .background(Color.warmGreenFill) + .clipShape(Capsule()) + } + Button(action: { model.cancel() }) { + Text(Strings.Buttons.cancelButton) + .warmFont(13, weight: .semibold) + .foregroundStyle(Color.warmInkSoft) + .padding(.horizontal, 16).padding(.vertical, 10) + .frame(minHeight: 44) + .overlay(Capsule().stroke(Color.warmLine, lineWidth: 1)) + } + } + .padding(.top, 6) + } + .padding(24) + .frame(maxWidth: 320) + .background(Color.warmSurface) + .cornerRadius(20) + .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.warmLine, lineWidth: 1)) + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.35).ignoresSafeArea()) // deliberate: dims the camera feed + .accessibilityAddTraits(.isModal) + .accessibilityFocused($focusedOverlay, equals: .failed(message)) + } +} diff --git a/Savely/Views/ExpensesTab/ExpenseTrackerView.swift b/Savely/Views/ExpensesTab/ExpenseTrackerView.swift index f990d8b..ca9b9de 100755 --- a/Savely/Views/ExpensesTab/ExpenseTrackerView.swift +++ b/Savely/Views/ExpensesTab/ExpenseTrackerView.swift @@ -4,7 +4,7 @@ import SwiftData struct ExpenseTrackerView: View { @Environment(\.modelContext) private var modelContext @StateObject private var viewModel = ExpenseTrackerViewModel() - @State private var showCameraView = false + @State private var scanner: ReceiptScanModel? @FocusState private var focusedField: Field? @ScaledMetric(relativeTo: .subheadline) private var amountFieldWidth: CGFloat = 70 @@ -55,7 +55,7 @@ struct ExpenseTrackerView: View { .padding(.top, 8) // — Scan receipt banner — - Button(action: { showCameraView = true }) { + Button(action: { scanner = ReceiptScanModel(expenseStore: viewModel) }) { HStack(spacing: 14) { RoundedRectangle(cornerRadius: 12) .fill(Color.warmOnInk.opacity(0.1)) @@ -65,7 +65,7 @@ struct ExpenseTrackerView: View { Text("Scan a receipt") .warmFont(15, weight: .semibold) .foregroundStyle(Color.warmOnInk) - Text("We'll read the total and category") + Text("We'll read the total, merchant and date") .warmFont(12) .foregroundStyle(Color.warmOnInk.opacity(0.65)) } @@ -79,10 +79,10 @@ struct ExpenseTrackerView: View { .cornerRadius(20) } // Full screen, same as the "+" shortcut — the scanner is a - // camera, not a form. - .fullScreenCover(isPresented: $showCameraView) { - let cameraViewModel = CameraViewModel(expenseViewModel: viewModel) - CameraView(viewModel: cameraViewModel) + // camera, not a form. The model is owned here (@State) so + // a body re-run while the cover is up cannot recreate it. + .fullScreenCover(item: $scanner) { model in + ReceiptScanFlowView(model: model) } // — Inline add — diff --git a/Savely/Views/MainNavigationView.swift b/Savely/Views/MainNavigationView.swift index 4ae5a24..2dcc11e 100755 --- a/Savely/Views/MainNavigationView.swift +++ b/Savely/Views/MainNavigationView.swift @@ -19,7 +19,7 @@ struct MainNavigationView: View { /// its own expense view model, exactly like the Money tab's banner does; /// the Money tab hears about the new row through the posted notification. @StateObject private var scanExpenseVM = ExpenseTrackerViewModel() - @State private var scanner: CameraViewModel? + @State private var scanner: ReceiptScanModel? var body: some View { ZStack(alignment: .bottom) { @@ -56,7 +56,7 @@ struct MainNavigationView: View { showActionSheet = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { scanExpenseVM.setModelContext(modelContext) - scanner = CameraViewModel(expenseViewModel: scanExpenseVM) + scanner = ReceiptScanModel(expenseStore: scanExpenseVM) } } ) @@ -70,8 +70,8 @@ struct MainNavigationView: View { AddGoalFlowView(isPresented: $showAddGoalFlow) .honorsReduceMotion() } - .fullScreenCover(item: $scanner) { vm in - CameraView(viewModel: vm) + .fullScreenCover(item: $scanner) { model in + ReceiptScanFlowView(model: model) } } } @@ -201,7 +201,7 @@ private let quickActions: [QuickAction] = [ QuickAction(icon: "wallet.pass", label: "Log expense", sub: "Coffee, groceries, anything", bg: .warmAmberSoft, fg: .warmAmber, kind: .screen(.expense)), QuickAction(icon: "arrow.up", label: "Log income", sub: "Paycheck, gift, side work", bg: .warmGreenSoft, fg: .warmGreen, kind: .screen(.income)), QuickAction(icon: "target", label: "Deposit to a goal", sub: "Move money toward a goal", bg: .warmSkySoft, fg: .warmSky, kind: .screen(.deposit)), - QuickAction(icon: "camera", label: "Scan a receipt", sub: "We'll read the total", bg: .warmClaySoft, fg: .warmClay, kind: .scan), + QuickAction(icon: "camera", label: "Scan a receipt", sub: "We'll read the total, merchant and date", bg: .warmClaySoft, fg: .warmClay, kind: .scan), QuickAction(icon: "plus", label: "New goal", sub: "Start something new", bg: .warmLilacSoft, fg: .warmLilac, kind: .newGoal), ] diff --git a/SavelyTests/Fixtures/Images/README.md b/SavelyTests/Fixtures/Images/README.md new file mode 100644 index 0000000..f020be6 --- /dev/null +++ b/SavelyTests/Fixtures/Images/README.md @@ -0,0 +1,6 @@ +# Synthetic receipt images + +`synthetic-oxxo.png` is RENDERED by a script (Courier text on a cream +background), not a photo of a real receipt — it exists only so +`ReceiptOCRIntegrationTests` can run the real Vision → ReceiptParser +pipeline on CI without a camera. Never commit photos of real receipts here. diff --git a/SavelyTests/Fixtures/Images/synthetic-oxxo.png b/SavelyTests/Fixtures/Images/synthetic-oxxo.png new file mode 100644 index 0000000000000000000000000000000000000000..203c402bbf92921a25855d4850e017ed9165a64a GIT binary patch literal 94503 zcmeFZbx_u8+b)cPNJxo*fV7BocS%b(NJ~rHbf*&1-Hmi3-5}lFEqT*OH+&b%=Xv+@ z?b)+u_FvyOv)?sq23%{v`*&Su9OrQy=Py7`MhpcB4+#ba21Wd%umTJW>=q0Rd=|n} z@L#s{b6{bfz`%$L3n)1y?=3!4dZmT`{11tW;0FP8ab!#kWK7JMR~VgiFJ95?{R*9i z!@`m;Lza^ZuS8a&=^z#s5RPU*$X?<0s0h8WDBGiBq<>gGayl3qNU0l;;c^A#Sw7WVO*5tyfrk6&T%KKw>~{Q3bF zVF7$V7?>|G-?$&Y!k`daJ%9X~{^Y-Z$$y{8|95ec^-nZcY01lv7Hc>jE;SHgp6m$q zb?@!H@gk0newDPdv$b1uIA1gTIqFGyc{!`rfI7+zI?M;V$DHW+i3jubA;WUH*_DvX zW#_amIeEF2i#{+ANnbQ9k=?O6G11g_ccIQ~_<;B3isq-=Y{s*bl-i+V9y`17O~}T^ zM&mITeSC%ab{pQTiv58Q%-4Fv#|&}I9Zr5Q-YwbNGk@Y3-q^U%hb6g}D-#*LK8s|1 z_np=+>`kea^2z3qcAXyLyl~CpjYHi*F~i}hi6R?BQ*m&VhYbvf|J14UQ!~9p3h!hS)INRcVRIkvVm()+P^s-MrWxd< zP)DoCV_2i{Gf84EDZYm&L;2uf@+w9+v0%QMfD?Ys+r;50*5ClCv0R)f#=k(?i=)>m z{Zx1Ee2P4zHoiw;xzs^87inW-TkJPa{`PIT^z^tH?kgt&aq$4N5}lS~zp&SL*Y-5? zr&GmeC2*QbN=kW0|N2|Z{&{?C%#%KwnVzvRh3Z9J2&B&PkPhCv)tEgdHnv2w>R>?4 za!Bn-oLg{kz0F`uWTYwQ!1?a9rTKVBh-(_Rt){wqAjCyYZT2)lW@Tl1Hiw#;{RLw4 zgp#VCEMlRt#$*fH6PTS>EHO`DE+OD~k};r{)|tHLhnqy!FX2eY$*c5lj3~Mjk}IZ) zm)of0N=nX;GE>iXCiRm%9z52m;_Q?AA1drAjNcA^W(aj5SSPAdov$wNBzg)nLHSsd z{d0#i$ViFG_x($>xFC!5@_(LmYN^oTgL9wn*ur7FI21eX8!Og`TeO(x$W7zib>I5K zI7P|Fhr^3XxT0y(T`kS)`WjK8v0zC>djBzgVISky`k)bQUOPN0ihI2-jD(i_v)%%^|S9Ua4StW#`Zl8pn_nTWnt_dxP=Ec&{pN9vvYFFq)jFz zWTUZcjAZXRnh{Ips!iNS^h={(-Q?{QEojy_?2m~@uPS#|n+}82xkM8J9ot?@Tbpn` zS~NsSSnWq(Ak%#Gg66sOAEqy#A5eC+oCfW+8M+-htmOMtMCJ>W!AbsZK?1ivVejiJ zVy+k+9WAUzVtnr9^|ZAD|6+UaD(9_28XsvDe||k=vihIn@p*hQE?nI69#(dC)o83u zD}%V07%YZEP!DpYQq+_>2%kq8aScdINML81D_0h)SAj&hF9a#~K`S@?*OJ%?$fAs* zKlow}&wQ`F3~1=+QrX&%g!;s^k#8@L^jFt$`8{sYvz?U7isvdGf42RfpY7%}MS|w} z$3*rp-Un1;-^>JQ(rRtK#5(hNU=kPaWi}e3q@%?6@pW}sWq;NW2IfH*WJ!97*=Ppt zeDuu)nFx3?haqXJxtiti3orh8VNkk;*tR0j2x)c7M5A8@x@%M!fb6=hA+dV?wZ23$ zZpd6QL#J_|XW*3u#MzmjNQBoYX{MO^=G~0XcdfPax`pGx_mt4giWNHwPta`>2~kCC#)Z&=W}siD%2)px2$?(A=Hx zbI+erMs=U`MW&_kcP_2;$JteHCnn-`hdTH5^vEe{IzslBn(&R!dlijsj7lRQlM|06 zo%^xaqKk@Zxt=rV&11;R%WA5WLd})i`m6U#rc0I$l4i>E{hHlx{vayo>uHxs$Hm2Q z+pk*bv$9SFyntVepKi^m=Q2mm%{7)v<%%lS6%R2l6g_`zM_wQnf7h^@seF_u*Lf+M z#>wxvw2IU%L_>)d(hCfyJr?1N0Ps8ft>l+a@h2vk%i~I96Yns|3B3BdQ z^yDHU)!U`oO$p4Fx@u~;X)jfV|Fg<^>@M_Lb)8Zs->i;pd_eBUkWJ%*RwaSVWp?X` z$D|mx8A{|b6hsS^_!61s`SqsRJ9%8S%*T=Dkp+w1dvX1!YFIJy*8mc)-AKf2xD za$a}#fBl+1?|2XsYh%*22#&1(ehGw75vIQWT9<5mY-($1e@=4pLhqViaENP#wCBY^ z(_*~%tw!Z^q3ZCserdvNxarb=f&`s}1U0h#cm9`CvR=e?_EoU3bU&*I!a_m-5TFB9 zv~7=xi3x{3d4Db&lXU}JH?u9ifT(C(WF(i(7tiq=uE}cJ>X{~q$4amDJ%=+|h;nQXCUb{?n(+>ofc*N=iziXKrcoCMMhC#-o#U z#XAd+0f-9%@T;duQ4Y4%tYgn_fwuiPkGo*+r}SSy(e7`+dMx}@kJHEJ+`K%igHF_Xf7)wMr{xP};wJpNJGT|bG+K$$Ce^34eU3^k%gaEpaj}P4L!^@XGUS1iovEQxJw8NPW|}E)pQx;?tc(os33KFY z=rYCT9S#iMpYz71q(Jx~=j~!))S9;5@F?epwyd};U+ptSx&sQ6ZouFK(3}%wsLN=k zxv3#5tJdw=D*2`L@wVa|7;3azLJd>$a;J|w7>7Znh$2!gopsu~q?~$YU}#taS>%`- zQt8+e#L;N%>+7>vs$cV@WVpTx3LY3Zwh?S9OAIXu&Gt1(TjC{vay;B#?e1-74kof@ zmrwL`X64Ye=sgI*@n?pmaa4G;$3CJ+)=HDbosNKZX?y#guw8a@&Q<%2#>Qj_;~HO6 zVY3;Q8q%RjO4pySs&`EeWQV zKRjDQ_EYBs7OsoDaFp?Nvm;vE5ArG&AEmABZ5{2`n$`K1y1FMy={*m%JL0VsXN*|Q z3S3V_{fmlduF4$K1i)-mTe5zzZ^hdbEBG+UdSe)2V`HOIXI^W^8Gf=Uj4ph#IaqD3 z&7SV5V=5tM72jS|XC72{xX5ang<|Xw)f!u7eN?2lzOmtP*GJ)VT@J_p5y9&x0i#>@ z`Cy|L?8^B}P<-4bHW?hvuUcydFr<~sU|e%-FI9M&?obFgV8|O%xhDJgY9+C?N7(j) zIiWk4P(0^M9p8%Z1?=?&uj6^G|Ew=v_3La9F3gsrYu&O}^laTGvHbmp zap-A%siP|GqI|CRrPp|Ut+1}&(P~J?A@lY_y@MO|GgW`6udnxosaYpXzEHugzDoP! z;GZ8uj3eMh{E6D{J1VQZy!>XB!Izn;Qk@p-6xJ`FJy}_m1=w)#945#5*qE7v5}7`K z;0M^=M{ceXGN{J-I2y$5YVTyGq|i>+4}c!Q%EBUiRcLnJPx>%Mk~4K5_q6sqE1bYv za&p;Zmc^41c~c27Z&W4wSDo!^3{X6a<+^U{Doi*=r$59$T8ac0qXny>JSAg;q>`SMxUqK{2}Yt3L_Djg@r>h^aV5O zn= zRZ7UmP4WW_GC=5&kuks9jF~Qr0s~?s=FREB_O5N>ek7N3-@q=r#a$?>G<+(Tvybha z$NiI&P5Mps4cn1%GcdtUh*47JhZB3k!n$kEt}XE>Dk!|{*U?hcQE_qU`h$@P{@fb| za2|p2;MQpSM;V#(yPLNUlHat&WU;ZZB5Z3c=UGfvIrxny zg))0VetxsleK>kNs5d8VhRry)h+bP`vkY5-MDl0CoHEUx54xs&$u62dO*bOzY>gCc z6&3jt644&+Q?6>A(i(O@&aV_DVUst!@5Ek|@~dkl(KT@S@X#zHg9@3iR8dm;C@zkI zgvz_+{FeTZg43}wWj}S1yS5}Nk^UX4(-oHQdt;Ve=E}~XO?qvXwB2`O%uK=c7qpaD zTF4AW19^)jo`l@ep zUPgQptKx8CgtqycrwN}KUQe`dRY2XSGzdYXc_%&Lhst_%#>eV9WoM%&yxwjLbq}CY zR9CNe+Lkz$(4t(uE-YN>SFioTi8V!Im+CsZJU?LWUv47=7>e`#`C|KLyew?>;f(vmfjg7dr%xR1x~NzV^Rg*D*i}LCT8(~=*#M0B`09|2 zuXW9)aJw$l%F-)OiUK4FCPB?Dp6SCj&H`D4G}t1xH*Z;Ou8LpD4}ED5g$J;z=d|vm zS(9kwF4cZ0t{z7!fu}j}fjEyEz1G{QgQ5`y0fBTJ3`WzXwkvbnKh5G~HjRXO?J`BF zl9AUpH+_Ay$;V@3jclPzyS?CP$VjQ>8GDVY`i}uW60x1aePQK5yBcYfUm+iHKk!~Q!p6KV90;BCb?I_u2^5)c4zB-Q47a@>Rj z)zZa0h9KYU;+*QG@{N|mT_O0S@Jv#UflBBkL|#<%dwqFB{l28kJ+aeK!B%NWAbk*3 z*muz=*21`xzM1-})DtgGgKI!-94<8(YkZKG|ArU0_O+XYDI)g#NI+7!Bg=D!2$Q)twvL$%^NS>o~KmWO!0@m|ng3%Jyjt41T*pI-(akP16k z6D+m;pgKnM@(*_VWuI_mYg|~sX1dgfdq!4qPV{!t>Z+J#bodK?lCDx_H(;o&jT$}f zRMh8;nRxA)T>Hsket?Po$mvz*o1GFJ9er9_X+BB3*IWGVZVy-EA4}Si6>25{0=YOi zYp^hByJTDk@W)EqI|VVr7%dZ|Uay^j2&*i+ti#nP?S8Q9-XuHKacV(V}zh}c*g9cBR)dWFX*PGsny_ca++{rP;YB&^z4Td+TYM( zVI-u$XD|72z78mZ>5_S=ob0SDRF}Q%p)cB0FLBUUxx5~NmsK*aw^VdooU4<|#`>m$Y%HJ+G7`g&Nmt`Uz_53BOwP-|-~?tb z#XW)^8NZh=Uj`*Z1MmJ|%byWZ2x6{Tw}r=^bGd!pNt9Bn(-Q~ykeWt3yFBRP5B5+O zidm1fI)Ia?E-tti_+w>?VZDk*2R4hYCsPCk##H;Lk*2k`DuJJdwtA!D?;-(wok=xv z`*`I9m90VI=GG|q!Si+Nu^g>Kal#QD;a(V)fO39v5aRYJF$Fy za|1(J|E5|R3F)l&v`cy(Aa}FdQtY+3n1qPlvobzRt5F!aSRVKL70f(E9u%22X=i5N$HxP401q?RTeq6C-2J&aOi=EE+DPlUcM{ zgr*TObC0&0w35&s9~nu8#<((7TXF}QRx>POdW-8tER)75Yac04ql8Wm;@}+*JP)>} zxOjdVzb%gzc*-fpfcz)ZOD(d2Zht;*Nz3W0#~rPC7b#)7!^# zA^A&N@1^jwez}-~&d$z>UHwFMOHtO>c}9bYJJ(KW83k0G4c)@>%-sPT{u>;+Ws;OU3?(&{*MJF)tUA3@%4A#L;dMq%% z&vV|6f>fUDu_k3XBBTJ!9`8M`(H8h=qrp^)-;N|s4rN43f>Gh&3P) zCwr^D6}wXKYynlx^u=bM#KXyZ&8m!=I~2T`v^PzXpkNQ*Ak;-hN82sd z#q7*hj~Qx#&W3V7sdrm$XK&ZjRc=7$agPk$+-!2|eB&6#%$3W*A*Fve^HaB-F*70T z^4Z&tI77gAy}%e#z-$^$Nw;VM)l#jiShZ~HvCyw@aC+W*6-H82Ylakt*rxW z2=(p1ua>zQZ#@F~)u0BIM~ZZIxoYPMEf={+=Tei)fK9x^ME{!ZI_-|{AmMW~==$c= zWoK5kmdCTVCCrH=w7ed-Uzi9muEO`E+>T!{ds!Cc7#Xn%ueUwDMSkjt9wzgBpu}g| zazPKWLy=xoRLX9-gl*89kwLn0t$KGI^KeXlB04Ye)i{o%^&Y_Eqq0#2g_d0-y|vZ( z>cfojN@-A?kxl?cw`p~wU`k0%U1<($vM8FnH$C!DJ3ajeCCF6z zw>u|VBks1uvq5MSMF?gFS=@Ual@^aK0IDWl6Yw>AP{=U(d81FS&}Qs#%AD5{5ig&; zcL^z~4UGblRqn7=-=HB|Kq{mAj-h87JPKYo5gHMv3`*yqvS9@{87)fH(xorUKazUU zsSBE8L+V}Dv>NV7#sNFM+!#=J6eLayV1r<=@hRx|y@HW`(wc50Q?H(s0c^{hUy0S`sOqZ9HcX=elqm18oZMl;6jnBvR z*>3zVjgXcG>Wn&#(NsqO8ncv1Iq)D>ve%bD0h zq5gD&1~@n1c)%#`aeIk@k1vKI*(d&kuj26LP^L_$5iL?>7J=&S9Wd9@#NQMiip2FSRvbd-}toNk$yFezh)(sH@IioSqX`iD&cE;6k}Gl zhLZ~S8ws54_Xqob z8fw}PmzzXIhBNZ^;zZDWLPM*kBVY|+bKBYkt938`IU~jHfcL`uGM~&k@C)+^j_oMA zrHMGwmw_tLvwd~dvO57NO%7E$&GQ0|#%FPHgm-p@@9yEop3; z)yZs3<&g+Piu=2pFd$R{MH`Rb8jn-Brl@FdQ`#)RbTqqSg=ML9xG1BgzM!RO*?F;A zaa*gz6=L2Eh<6amM83YhWo6bH_p>IZe7EDdEH>XzcBhp%NE?EKgFznygvx}enecQ; zifRMHyb!ZJZAR;Hv-rmLqVszOXwy}qoCqK<63&^pa zOt*Pwb%jSm;ODRi)RgO)kh^1sa8m80voR zcRaUi8kd~*H>O_@p4jirkkG6%37}t)5e_8shE*iRRaZ|jmU^%Ao&gQ#iT8klBEP&X znAVXSUy`os5ID`onHFe3bf!jryqkz#N2Ap5lG$`qBc=vZW(+mREc}G9C3v;a44&`M9(7-im~ZO&iEhgN0lm+xSIh zkg^)y!rcIwLY|1*?hBDsub;UC{V8Gp&`@<6n?^X-5GZPU%TS5tu3aFoV}wQh5P~N< zA=r9bw}pxqmALjbtki3~_qpS{>xp5FEE zUTnA!>7}Emr_e0x%+R=KHZe(irdICZ?o4zzuEm8P=n?L3!+7Ox#5qtL(O>$>-v-5C zZN=K@gr7Sv43L1NWEMzFdG9|M{s2r%@6XIuKvaS0@=E6?7p}u;gOkOUTof`6z4ek! z4@F&A8c!yli>uE1+yR)T9R&n_JY8Z_uQs>7ie%q~Spnd za~_CIq%~_6N!!mhdJt&L=B3!IZ9=pcUmS?>TwWduC;{2Gtm_Db!jG<7Y?m-{&yN^C z4Fw$>96o-j1wAAPhhCY@6o;N9G%Ec!!_b8{c3d=^HX_M5NL1*H2G6u1#{)wH1OIrf zExn*;P<-t=CWaBT7quhV6}llhQXXk)taeve$nquM%)3IXGQYpQ=C!)dI`W_U5eRe_ zMzb;DyKrc8kAD)iD$Vxt@+YCpfZ*U4-mN1eU&gX4endqH{UT5MP0W8>!aU}{vD+D% zkvl!+zp?mDzdLl*1!Hfq&@U#2vs7giz;+aTHuKqX-;hCuVebQWOP_F|V0ri#Tisl# z;)W;$rfv3lOb|$Mv!^zK#MF8So=JQC*DcSg&`@moeI0CkkcD?Qnx#6(Ht{CQC0ZH) zF@TZ8#Ka^q0cEzq7`n)<+H0yGnC**e_Cd;IG{f03vt&XHP%k~zJ?4O5r~mw^-2_NB zji@gH|7&OWaCX@sj(Ij&7^g@i4Ua;sBS7CrXf~x3XDZjP1GsIP0qIWrI6dv&Ts6dsIe%Q_uAOdSl*KJ82wn6dgH$zo+9{8O@{!%OLFT z>{_nJrTSa~CIommI9}KL)&4koL&J@oosb56djjsM5-`>yRXkkBcy37isoJesY77`0 z=%b>cQBqJaozJNX=>2uHwg-q`Vy7z{I_;G;+lXr?+S|Zp5AN0q;G3!ue}#3Mx6K3t zSqO7tn76(M>0dW=cE$-=$i_d{4pkPLuott@OSZUhK#V2#k#?sQ|Mb`)bw6L(T*({2TMg*1@-14fb$b}N`rW^i; z2(yn1*pZv_1Hg;vB@tpfK$=e?8ynNi$It%Vcuod2s)+-w5E&k&~BK$v7 zyMX8I#-xIX&qYcpOTZic=Atg~1>P^SCr>Uui5lfJ-R(g~=`WKJ{zJIx_%Gp#hot=Y z_&7eUl$4Z^FVN);))@;ocT#dmR^Drzo5r?1P{DK>?Ua=!FiUJ{2c#Dls*gCI(_d>X z)tZ>8OsSUGDl6kZN+Uok5x5V}l}*!_4>sZzEa0*#-D7-rYC_(Zpj?R1-0jm053V4< z!w9%Ox*ilh7(UcjP6%U^-ewR{z}%8p`AK|gu&9_G#cbUCm&U_3YC6+nJp5pPl>LV1 zr}3o~HPd3X7BqQ5vm|IzDQ5m(3XtZEL&qP2d3JEI@`{>^3tEda7oxsE0SdHjVn2g})R=tT@h;A+DYuIlsx*Wvs8{4^>W3gZq=$0a>&$G*QpOupsF{*B`S z>$A~eA{G_JOuo&{&0@_enedwf;FNlfwns8fucw`;5ljrnY>}L@J5-!JPF>Y~uo(23 z*#c>v2@KaujTofV)RG>1i^g)tey>UlYhAfDWlPCP65HC^bldj zG>-kE0qo2Vb8x8E+?BgIW1borAr?NrlH{7s)A|q^ikz3XXffgcGrV8?P`g{0pUd&w zDUQE;9=IABF5di3Z{SHjFo z)}H6O92vQc)5@qXy6>>4g5R9_FG-~o0ie^8c`$R`ibfX;=RoK{`4A7W^0@WKv!0iyv2ay2W-Q;OHAAef}&l;X-4xp{G(E}aO+4&P`&M0{`Dbk z8i(LwGa)kaCCNJ=WT7IVj*pv-W;VzVr!J=Phw*J~lYb1k0VuLNG)*EW1!q95+Kyy* zVQg%SQjx%7>H3bGii)by4Wn~5d3&kJ-}xl&5zsAGjQ8YG&oAhA%NA=_L`W^y5-yZL z@U_W%`>stF92uZOSp|94I2*4q`%TCf5|q7#d8mCG-~*kE{56Pb3rA$(%}Rig%UPT_Wy-~GkiCUYRhE{ zppN_;>Bqy9?s=wCy7cFG!z=^)Q?m^U77?KT(U49n@L2^F6}?+O=M}KwnKN0XH59hA zuuqzMn_)Fub<}o)DH4ZXOWd7R8IsmuHr6mb-BxP8R5{f*2H4jhUjg$RAK@1mczo*| zYHka-YHY4y@^K)nTk5TGOpgEu%oY?40C5}9eK>&^Z$`o-Jg*{$a^C{xIy2)uqPjg` z&Lw#Cv@+vRO|8N>c0~2Q-Z}Zh8Q4u|>lqx(B#ZzVKA6ItBt=0^E+%YKI-S+l2RxaY zE%y!QzS6*Vg>`}dddrb%?_m7XR6HDd+SNPh>fJu>g@uU zp~aQJz62dkCDt$o@Y#H}J}S@T2+AjKb3QLkTnQ^=K{#D{BbA;PVIk49^O%NG@J>u0um}RD!&TeAxmmVLwL(q4#QjD z5pY#f6qbMD<_swB&a)H{%Ir;??03e>v^V30KC|sP1?jNd{z4;HITMroDl?U1 zHJjLW7L<^8072!>%VPKEbXIj67#QT`bxuNbY>uC2U@NLxz9GazC&K5Q8;ae1y2Ll| z+zceUi3w8k=4o(n(*a=;8zVkvrP=(WH%v2*hL%=qyF672I1DZ3>*c*Hh5q32bbWDA zpB$=C2scZAZ;WkEUh*fA=Je!=_TO8t68McuS{3z(0Zw3nIx-#QLy zEN^UDn&*W!TtX^~^&9Wj--_|QQ514##s+4ac&G0JIV1I3n!9e>g@L%28+OR=iPvkN z+}!tajW8--)p8BqNNm8%%PYJ8{w!roQqF27AZkFr$lk08Fux}{e7wBv18t&`xiNof z(3(RlGJIZ8kL&AgaDVyP?Oo>2@P}Bv3nn7#xpZN*2_C(uEbukX{yP0Zeu%~|Vf@vr z9ngKPtDorW8=AFY@JKr_VnM9kd$r7 zdVek{S+32_FV4*`X15-*Gu7j4Ej0ugPi4j_+aKjy&g!_w$yu=l&!qZ z&T@N;IRTrzL$bun*4CAXN5V&XyaHGvI20{Jq!i}vgd)?J@bKR{lM+aXO?a3mffZFV zB`F$CRc`$~16f^&CKDeGC;P~SA-r8%uIiJi6$<`l=_AIm-@bVWE(ArdUjn1XiB2&P zq5(HHu7FAXV&Pfc@$~E(Tmi~c$GpmurGb8O@7DAJpO_eY^-uA~mb96<)8(>s#O)WZ9y-7;|ocHLcobnSftUiv8{OEJ#Rkq6Bwt%q)N4`iWCk%6>sVXH|JtW z5BJU48Y%De=@^G$=CL6x{IHRbkhr@|5V`^nQjWSSgYxC<-d$ncTTHV;_I`YyhMAfA zRQ6&Dkl8!rP#|!;x{G0%EK*a{B05x@Xn%h6(mD)r%uUfNuYiP@s`yyTwNr-1RZ3A& zf5XOT!&*irG^qcujDEXqa?pL731{b7nq(Y@Ex}q$R8Eel)0Yp&H|$}A3+FsrG9j}U zU{B;`yMj|kGi}`etmV; zYbr)Y*a@gWv)?7@@gx(wE56uJ-nIZzg^R%EHa`KKx0g^pJG;C!RY>KR(MCU*!#iAa zCI(toMq1V*V6*hnsq&rCHw}4c_BsXRA{KTqUT$fGDav%7_?^jt+F*N=awlODlc8YE zLyJQovHzng^V3YYSXEP@-0BEv2NDN3h9tIQ~e=NR}kdl5L<(L8{i$hzA zJf~|Q`u04c{Wf$scfyHvV-?&%~%DLm`S(f4+OyWPenq#yDJ_0E#c}t71ipb zi2bTh*@SBWuw4`3r2V-(QaoO}Jj(2K%$oMNyT*O~{EF3VD*AS7ZC5O$g~UJn^F!rC z$K}iy8`X}k&Kk(FNUllJpFg`9hm6k7bsp|Fa{U$6!BD&FQ_ccX$jGmj(teB)!VB2- z8P5r3Y(v9M$R%_*tg28b4z0!^`}%W`W0yE0ffZHd5$ zas9e*XgqK?L0Pe2tEgy#_2+rA8kY7|;T6X>E`)E~N^OrVyD%7N>;SSVk;Cos@3eB2 z*5m=EJU*U54F1p28Nb^)M?!35WI=|}sTcp6v8PqgYt#KyNQ4 zE$w2(#l@ofDX(bA&=qh*sw|bBJ6#;`;u3{hQsQJdKA9AVmDt*5^h>?Gzv~7*K26)j zy7o6{es3BR^lm76Zf>uF{JM4Tlg|Gw;7}3n3!^Jnc5e>a0w!8k*4T1gOOnU!bOyNS z7>@|9y@dSj&6b6W5ZEVVK{kqmVe|>22zHbS|l=rs@R-XSG0F$;V$1U(v z1^;BVJt2kM^T)~f&ygXYC_CGM^yHI@?d9QkskiT%T`&DPVHogt2K~X-M*4RjA|9vo zoSg5!eGy;0IGA+(yj%Uf!e}^CJ63zp&aSd4^bK))1n3|?p6}Q#+8iD#&HX21|M$G3<0Ials)bDOg#I3Vu~0Q+xbHSSfEe+d#!0UqY=MTr5v_EY0hsN6v8|&+hE@zuSQfn0|&ilSs@g@hO z%KLryC+QRwR#%jw){63})PGJC3Qm+TAmb?=FT#^gDV)1a4vU*!PdC3bzXH+@MX0mF zp`vDsZa+pTW-^)sAQEtat^x5eLJMmLcQ4>>UQmdMYpwW4mmCO&{0_HDJNl)mi+6NP z)aM-=V9SEpcyQwyeF|99=;_hG28e0jCE|I_;X)>#!`6t$9ami43AQ{LJVKs+H>xg4 zT;0%1y?=NIW(OuHwW^x9ghYvEnN#REjcMRtjxB!_<{3-33AS1GDBX0E=5E4!FjIMz zUb33By=jl11nG-OA)WhuaAnQ$d7t-zkY8drQAI_?e(vDrS+bwT;(Z z4veP``*T#3R8xhjvY;monD;F4avv_$^{MRwH`m9q$~P$-x_j+C8bLUNhXyn9V2QT% zGg!MRD`(-h-WwrrIk|O|iF&u=$8#Yfx}XV+RS)-FAjH8G*u!eKZN9a>j&9XU!O>GH zX)H9Q3Ajgv5bGEk{v#t_=?N%x<;Hz*~mLX5RCe0r-6K6T%qI(YM|_7J-ooxw*Rk-aWYb#f4mqrU>^T_F?eiWK3lLwKYUNz**V==6w*}n z08Vp^Fie>H5heIXQROSV+}i*3uRZv?SPx?_>Fj(}Jnsm251`^7f&H)YavR{nUks@a zB`}+=@ROh1AV(kt8|r|6@bYetjO2fXjcxjcWqY>$%T9CvU>b0;)-w!ntjA#h%_l71 zinfOUQ?favvFAydUd~NH%NikJY-~I)n{)>@h>>hrzl?l-;8TqffUO-G92!bxTLiwK z_xIEh(b2#+tlHwF9i3ES z1QIsd`sZkwz>5_Za~3oG^o5BD(2Yxf%E zWJd&ocLmI|6-!G$uQ6HdvktWddPHgabI{SHCYUDGj+NciL(w9AY z!UZz-&4If;Kz$Q1Ec=jPPGEgeyvTh#0WE?|Ov z>->)ULTB%Jo2*V{moF z2CHiH9;pQ$%zL09$oVF+PGYUEu&S+DfL#n@-X<4^QY&&mc!H;^tYlG^%NyoYl`YK% z+evT&!34|_f`VSQPZY;nQSe!a5%jj?Fu}_Vv^5Y>8qUEiTO9zIt3G(t*7W{^;8##k zNT%MK9{waG=c95}#8>3*PKsbWUirq(e`^Lh0gZ%W6aILVSG8phPw5h3 z&z%QqDP_|{Fsr_2u05qXuEu92rCGNDd?@hU4yEueWiy|=Z~?ZBi&WUv!J7vPtli!b zHQ>QM%D4iT^0vu!f7iXhupfKc6kq3(xUupxMG-=2*8d1u~_?&b0cD z^XTth$3~GgbbV*@VCTGH=##YcS?7sg#-2MfauQfMY%JITE2k&Gid~M%x3fY}V#eWd zTiKt80w(}A8{BWvhwT%#9xKS-|~z#f2=(3`E?5)H+Ztrjbe4pfQTV`G`9g`PBMpc*F01Ytmq1$4<@lUxHCt$ zenw~A2flMa#gmVN_DrKuA8u~}mP(`ZQQ0l;_%r|ktKICc2Z<(^(Ei7Zq|0jlHbJ}0 z?FjJE6GgIVWAKUbQ6am4b*r%L#vY0ciqz_mhJ~S?04vMXTdobUa}u*C#| zv@{C5_m6_PFF;vwKPb7sUs76oULq@LD!flq=OuUt%FE&0)o|?KZVx3e;jmlUJnX9v z>aD%(@d2Wl*{#GQufN3;C-vyR3<$wgU0RMo&W&$+3O073w+#Yyv9X(bYRo;SU^WGB z!{C&plW@RbM|reu!NSM_uAStEYHwwB_UuAkMXw!*IpM7IFk}ERY`1=@hM24Gf%ghX zzXQ6Q=+tK5d%hLOhY#opghqzV9w|YUEOAr9U}fRad76XIsz74;ZBFMHKz?==MY8d* zG(IxZCrK+RU?Ty%IK^zbXnoEswknZ_okCU98PV&JQcL)W-tlp+N23(Mi4nI?Iem)5$<@ep(G#wbuMoX z_UY@uie*6}Rl-pJAo#H0f2{m?0*s!GTzW1ynxBh9uvM1;7cekD!?buMYv!Z^Kuscx zJjFOL9O))e|2;}`aFl=68Eg{)N&%w6(DQNgBAUTo0eNit#0QW$|LhM;dYk)6 z$bk0>K$oZGa3d z$#VJrJbUwOL1=$C?9EW<^xt;-3#bM#vfx$_t?3ma*Pf&74STXvQnNR@a`OOot>sm1 zi~G&NtnFs)-v8i>djMa2qx}ck@*RV1YNF>9@LIzu7sb`mLGKwDJ}GL1-R1&EEVQHv zhlAc7tQ8~|mM&W?Eg*UHBfcl!AWKM3r5F*RrFXU#?_JHN&X*?x8%rdnHPiw>Y&SWg z&=|p9`z3!zp7~P7svZ?BZRcHQ&85vsR5M90|X^x_V|HC@np5^xiCBPVE@L^nrn* zU(_VAD%Fqf`2b+(${#|k8lSc03zb36TXED>p)LGb{(;8%$yWz(s}l-f6VzmJ$|?rg z)*#qaML}T)I{YSizO60V-uz4)vpXUHyu^c)h-bBhP+B@9ravxxJ309cP8skKfy~rF0R}16X4H_p*qL3lpI$x^HzQGHzsT;_ z%xfM3Y%4O~@BH`RbA&#A4$rJ_WjsE{r|S8{Vxd;Vo)Q@WA!9D&i|KC2 z8`e~Hsqn-@O2v0}HxEH+2wn)Nh{(tdDv%`X#HuPobyL!_Lm`Dd?;Z_}huQQWqduC% zj7N**8w1$48R6H=FN3VejHc4(bZo$6&^^mMl*Zk9=5A0W0Im#7?L9c$;l-NZB}76Y zJMX(1l{BK4mvO@UM3ho^=VAVk8a)EJ$H@8siB3omss02I3P?u5A_0klm&WW+7)rp7 z<~_2bx4YzXu(p4mGT)glAuq`Qn7FsD+DB&@?oaDw9nHY(FbG;p6CV zlTrjhx)h|l8>AZqCLrA)Iq8mPOmVHf_TFpn`*}az?{T~@haX&vV=X3gUf2H`;~eMt z8^4Z4kp53SPX;3_DoY?%uICXHO=O+sha7b;^KxvmC5IUn1s0Un22pNsj^rG^KG7o6 zdsejh{X0T&!Tr4uY?p;3BBeRGCTmL@n zN&edWV4;NM*jvcN$VJ)DBfNF*&@AOPRJk91-`kcDvPDLaL+^r8# zFvvDG>V|p-&OfCx8}go<=TNm7NDxE2p{+*!r<7M46aos$H>c3EVIu%T0}qFik_ILw zCOF;xON-@L5=a5P?vh8A-0KyT0gnr?NjGrz?)b8y+!r5BE|yE{I_yUnYN z2IUZWQn&_2E#=5`>aojDf< z`SsYD`f|qv5dS9#`9;uBcsei{D~vg=oqrMvnHWj1SSvWyyx^OSn( z&kOn+jU+q2L5@K4Bt=J(AJnh#Pp?RLT!km!4_tWlRP4|O>2^0PI3#2++m&&a;f~oJ z42_eM4c}yCsFh0fC%F!>m0l+3T?rpd5HmKqNw|hxcF?E)qjOAoCrC?v1_o*_I149& z+7aEU^hLOmG6ED*ei(Gftz1+$uR3?{hIV()TuqF9w+~e&#G9=R{8Cd{XClb~cjnT6 z=jcoB$t5BQk38AWTCdMRf_Im$TUH@a0ZA0u4ThNbfm7>!a9m+p&lvMH>EC*NZi{!u zc12GV!DHVCNl^$9r$MhyN|T#hwI^yZg=!ktQm+MN6Hf9bCi3(2F?NoZw9aL{#>y zXb=$rWXJ&Lw)`4VHx3* zMZJLkJ&-7gTK8Fa0es~5DmWeIRduIo5E3zv%$1Yy>%WanWPf}vY2Wnvnp-r7D=ixvHRUe9mev=DJj0VGK|Jio z7k+0Rm8Pa9!@&y7n6ofia!TLWS{|t~<_1Jdq1h-T)t$Bu>L4QmHmjZ<0(;hZ4*1ME zhy<~Z!YbXSHHvSzEbXPUnrk!W=DiISHQYD~6`g$Hxa64#DA) z_e7?H#aPa_P~fJX5mNB|h2J41mC`DyD05L=v~F*!tFO~2bD+GlrUmUGpW91eGHhn% zo&ri zC~LL?GN1B0g_X|RZkq$XP|VCm)@eS>(70>_A)3Jrct;Z3EJU9@(~F=5bm1_cGi zmoxTVCv?o3{G{Jf8FR>=Qki;c`A>Vt$%J%xFcr7U$(c@7*!O|0nB5>KB&4Ct1^fEy ziWFipDJikPzkkXG#I+1h%RlYNiWGY%Sf0Hi1LP?v&rvwYG8Fv8YUk16-dfp_&xw~9 zf+mRx5Q0c*L6z$?gZ4#Bw%gY%v<9!&*qe+(DoRM~!uj)7k&~+ZzF;sWzgMOk1w;Kp zs~!$;v0~2<@(j(7z2trq>Q$sBl`}gvm1fm%F=s_$d`-}Bq^uv>|DyVjUzv?r3Ayb8 zI&dPGq^@2pOG-|zGQflXUL?5W=FOWc%46_UW6UK@lj-Va1h-^Yckyx8K4HM}2tCkp ze&TNj)2J+hLlMl6roAv=tVt7Ey;%ATI{39sb{dN9?VYbXJF2Qh4rgP^rmkXxRm6mO z8?sbD2ZiyXhqa^n%l&fa>pJyfS`k0el9$^ENTH>gY8Y`n2e#XB<@aE`+1&WyPW=&# ze<&v~HmV;`^I%y{i=>*?9r__2RrI5oHv>aXbz3a`~(2;Cg5G0O^N z*D96)h{>V?OOU)^O|vhyrsAR>kKZ3r10LhyKEYLdL<~*egOE*K1nOFH=sWr_!S;EL|~+%y!;jvbF-cFW@gK>&Y?6BeJJEcuxkr1=T(Q>cg{anQK3R9d=YFv z=kg8LSqkvChlaR7283>E4W4v_2@(-qiS`n3TQ4p~Lg?<)DZZC4tLn(fr%u<`m%Elr zcpq*1psq~Fa9$#&4>vN>)uCv>NM+#V;I#gxd zLvP`fBR!9*oBJ}=wNmBnLz!9cv%NQo-dKG0ri3GeSRclDSus2j2mQcbn=aE_hW1L8 z|CxM8bY&JkOMqymB0yZYgp7J&n0-d96;9n7==53#p;4R#E18zO4w@ZccYmY#dyvbp zRHoUU6OXAh3(f%Xuz8F1USyoOl+^h2yCmpLveU=^N2_jc@!z|F>k{ju)?ZM7zveCa zZtfxV>iX4CR(JZa56^4W$?@@qdWJZ+ zr}`j?WHnK|&Z9jf)S{5_rCP%?Y>V%un_ZmXb0Z_L@&XA3LgmZW_M6+=2?M-Y1wolr zOFjXvV9=FCRD5?_)^j(9`x}%G7!C{;nime}IV%3t1Mej$!wTjOyKBrUKCxL%lNTD}0x{UD&dPFulWpTkJsM9dGw`i5wGOLrKNW5ec+XF7BsV%V|o3&)b z$Fn>4USe>C=COT8`ewHK(M6EPdo?ic0^H|qyLFR|nb}Ch54LvZjio_~z1`Ok_JUv< z-?cLs8*yEcekk1Ky>e$)nuBJDYJ#H%yu=r`6+Q(A)2f%`e0@`{R{DYku&_%#;NE?! zasln20Y&Vi1t?6tUoKhqg;jG$=9MwOi|WZwT&f3OydYuVZfyn#4Q__c7n`N7rXiF2 zVP=S@0vqN}YvG+a9LSHD3cUOecdo=ocrL~8I=@;Jkp!!k-hz0+*}KQ+K%O|?>n$y< znlTSpo5P)|+n$=cIRe2r`foJNhATwwEecv1&e5Auub-!U@SwysVwctDZJPxZz>EY0 z-h#0s zdUwK3pvi3}jWrCib2<8(R_b^vg}v754?RV}e|ro=p)~8umpOl_42kis`Yp~7`)2?i zuK+j27ijlnNHt+FF8+ZF73GPYr$2|jiL3eL+;bOS9f$QOX1@d#) zTd`CY!oh+CE=22IOnRvU^9O}L47+WqgQl0*d6(FrV_NHg%3+UK)`Qvj!!2V&QB#{6$HelNBlX^!mqaAoy!oBbgoVL>AnzQXu z=tDy7m?69BE8rG#-P(`>9Xdl*X@rfv0mMrJJ@G2{Yp{cL05;{FjPUoqlRa6YIv-|0n zipLYB_WN6o9&bDX-(4;&m~eRnx$J14%B3X=*Tb!z?(T*8{0N9i^<>hG0!fSBj?D9F z;lzO{ggIFsv@H~ShKE1Eb8$c1v)9V#O&hM=TZJem#4G_4=dzxV4}2cd2W0oz7HgmB z6GQ`0S~>4|yZi15Viho|`p)X4R8$M|B|%B288+ulhq9d7hXm01;F(RWHe*$_{eG<9 zkB}F8Kk=~R&8iNEJp*KTOCB^|K+6N~E?Ou|RdRE3Vq>FscJ`!9K_j?}b7&~hItq_) z-L~b!{g05iXJJ`xD;5=Q7GkKv50XD~y^zXg>nbHN^x{oS3rQoYl#~zbzW4dLU0-+p zxxwo@@C;NUjgnf&K|2MjsN5(V3w`sH)Lbp@{acjid}U&yU%OyHiqtV#TTtNH%?5S8 zJqqGRLx51-_Mhb`s2>ujtCI!D^?utceCLWOiOeBkN@{A=lGga7q|n@LMmmL8k7wJ} zp62(>9;3avdBchM-VFBah8ky~4fyS4eL+P&I(n%a5eXxv+4lVp#H`Bx?*TW2uwe4V zPfk-V{^}!q9?j-WqB&5D!Hf$Qb@(%^Z(3rrz&L1l${11MI+(CLT)Ns69lqz?I+D8m zC`B+(A!6siWk&Ihk&&;L5!^*@+oQuiDkT>nfTuzuQDOC7V|nW5_XJxi9!FL)#kknm zR(5U*PETQ^?KV8nuYIdfnn`y`KLQ>gAZ|lFMI=zM}qp zrKcYaqhQ!YkSXnJ*hc`Lp|1G#oB1i_^+9Z*?6Y$eBzzVc32A920~`I2HrSnt;&i{R zq|)J0MK2o*tp|Vf{)A2(a_Ay4h;i)nfN)z_c73DxOhhDIHTwzQmx|4Y??7-!S1E1I zihG%smIkQ{fN|5Rvr&{5Jd6WrO_8$=-hR>n-55z(Ax4Im+c^F$lw8 zW*!*kjV>m3%xvOlbs8EOrKz^3(RF~#U^&j(%rgh14v4Td<$aXu>x>K2u{@3DkLvjB zVHS*BMTQ9bh|hOuQa|?ls6aM|%~7hX!~Eb7Dwm5Pi5$i((uWE?N5EXj9M#;y`QFvV zl{LDw*+M}apq=?vBb%`5_Wg%IylZMI4vlxk^iIdGNX`(4&jq9h7%xHR&*j#dtHG;d z)w?>GnC!uw)~4xpJRU!!QY{%Y8cCKMsLw7c;^jQ<_&sXAUf2+NXRqtMy5UZZHO5q! zxS0UUq~HZqv&le_2mW>GN0-j=3keN6g$gJ(PAAKRzRuItbKP8Fdn_A<64)2&Z#VI< z-KC~p#E*Fk<9CI0WuXK(&E2-2VRtJg0Vo_k&d(W)%!H!lOed1DJS|r~y41f@&o7cv|6vhsyqz>FEO}l^ z4;(gX_Ga-MbwhNp&8fR|t&s)!GcMp&@R7 zWIdLNBK5vL2xm4HcH3CpjPpPyNy?}ZTCMOvyxM{J9a8uCqkE7k5RmIVIlJsmT)+N6 z0B&FJa7NjF1%aVkX?|WKwUc`iLN6i_bP8!fvNF)zGO{$I$Og# zUDtdHW40e(y*aQySes(P6OhkWtp7_*<;c|sBoAU~=;&tgi=e;A&M5#x-HQVdPh6^I z^Mc|(Pkvv&LrM9pyz4$lcrZ{?rxU5r$~HtayN3zFEWzFlY0XrKB~^cTl>VJNji z_XnfIOw?@%#VDb#+x@Xrz)i#4**D9S>48O5kd~|cPenwexHXd9@@}4c z&6nT%^qJO&whrSTLcKHel~ne}c`qt0R&N+Ek`fd1GDu~rb5HVcdpC>h$W*1J4f(H! zI{p&xO|>UD*Wym(`+4*0*O|pXL`G5{YjFs$!f1e5*~O{kRyYEci~4R(x95cRYY>LU z3A~O>GK|LO9=Ee3RQI2cy>R0`&Wjk$wr1@){iYjvz2Xm3?aKv4x1M5X6+j&Tlt~wg zugPjg_z3I94M@_3hinJFuZBegdQqwI@y`T;6*4Rg`&I$6T*NlB@)1V&ld9iLerhtT zZYXU;aW2LoG)ipV3Kts1H*grw$4KGB?92J~K_6YPAF<3f@ygxq-WqCrmV-XY0Cc{g zV(ZZ?>yB}PYw>Wh}gCrbauA?NzrOyK@sxLEe6qC><6Cg6$U zF=X9)a>Sd{9|K5V6{HTw z{1yfV@FwZ-9-~<$(NsY@Eiv zIlu7pdEXAycohw%oIxYgC4KdFi%?Slodzh_w+ILdr(>cLW@?gu+STkadk}`vSU&|O z4oYX|fjD579xdPkfglILgzj!eusaI+MLm)CWW03==IxR0`;fkXY-H00b;GWZUzDiW zp{$}}C~#4?qYtN6R;F6sZ)^@`>oYAre`M-3o52k=v7VkQ4K;O{>umIJsP`1zWjwqv z@^?|iix%5w0G9>MVrFJy@KRCH@{odA7f1@~BOO1A95Ul5T4{tNEqC(_-A83qfcXPC z_Bs&-X_|M?e7J9I&_f;AyGe2Pt{pRjIw%XX37Kk@hMGUhg)&dUd}$JEBw&z+hNi8l ziF9<2$sq~5C)@?%WU=!ghO&v>Mxvd2!66=-^ulo%{YA;xALiV$k^f-Mtt^d*>wfiv zce6AS))j0m4KwSW&-Fg509k19NT%{?ds7DIJ480b9Edm9FrY{h-a3Jt9PQ8tNzj79 z;)HTi$G!mAm{hH}2e*qX+UBu8cbye;sbm1KCm6E z2J?gR>1?5^y5z~eeN=`Fz6uRTd8s{sDo8P^W0;+ZHaXwKrl;GsE@*mQsifL;KWrv` z*htO1iN(yi+{Rd-L1uF0w{O??6QC>H*LJjIOU@wpAQGIUVm4IPMs*&}xG|mP$~z(` z2d(7+w2I3@Pu9E#n&=JXtq{nBMOz4oxMnuNvcl}Y&0+xuAzc|xd`eKWXXaTQ~|(I{_6Ljg85Soe2E}0Oy7P!gAMqMJSG}EBTKP-`ZbDrnGF_WfFYli9Q6g}Uy zKW(*dmk4%01>bv$5ck;Ot7_#x1V|~gox4ss&f8>7d9X$YY`)&jV*~kUQ8K6I8Ni@} zpn+@FHk8@Vc$mh(7dljfb|;kPA{ucCKTChpr~kt`IEft6HJ(XX#*|VWQC>Xp1u{NX2okFkB&-JE@VEgLOpjtB^A0Q{KS4R#pVx)PoZ{h%J z7Lbt<5tsAv%;dCin?Rq1HyQRoxb9JNFp}kFC4pB=3LJ?T5tp*?c-|Xbsu5Bin1uxa zBjxtIl$5)m=L-nb1=ECIzWu`;Mr|p_MC{RGc6M9+))j!0p(N%#I@lFaQ%ko;s>@JF zd_E^PH4WqSQ!lHoU%xtX?;d1oXm~3hsjFvz)B<$U<^J6LBq^52*Hf|u^@l584kIU9 zr=|}tmECYby6HYX80^sKf^pcevLJlHOKZVtbKab%2Xb^DzHycD5FAlRPzs^l-i@;_ zm~?5^(o!@YdIp;wihtTG)#BSj$`PyK3QCW(LniIG_9l9{H%h{QpbA{$D+5 zc~p{siAItF#b3cv>59?|n40wonXU z+SS#Pfju&i>iXFkC?w?vf|L31CG3ZN>HozJeEqoW|6;UpUeO9{*7J9p%QHU&-hZ$o z)vcGL_J=3981R8jPfq5;SBbeZ9*Ud102V?}yGP)Buc^q!lxkDnb36wirx^RujLX+!AR~Q1)i@+2a zPSa=;(l6t(V{q81^+NvPVsS!$u(zLx<~x`ND;Bg)&-h*g2<%#1W+t%#;>bD9e%Td{ zq58H#U4*!)m#sDL5pr|k2fwFZSj6Sw{B>f08JR2;J8hoJ6hT9sxRIIjsc7z(4Ci3* zfO73}uyS+jRDYc_;f#@$moM0jAxtq!QC-Ku!MQ?Uwhf2+xbP-~ki9-mItYxQ^SycF zk(ijcj{=Sa>O@RWT7x+b+eJP)Xw$Y?76Zl;NwBdnNg7HxAo1&@u8Bx=JeBBhfV`eS zWeeg-rVd5&H#ODmHh9Ei3ocD9wKnwmT^cgp3Z`i;zxusi4f>HD?1!>Os2oBu<)-Dg z7jBTKc>6;;q9C2qom7R z48M7+D(%pZ=6%sM350jx)dw13N@i)WLCI|3_JI zE?!rqyFWPg;EAXFbK(@*qqh@SBUkA2Z>;cf8y8V$?CXO(_{%1Zx;*IPgnaC~d)$#{ zn1aUpWa}G~jDe_XHBm370^=6)4u4R2r)uFaNLMTlPEWo>p}=Yne_oNmZVL@Acg1ln z))PGZb#(e4x?{&?^p{6pfvaX#P#wsal7%0@X&h z)|CsicPDn&Zzn5(ig%fbzN2Ly_Thn+X1DyKI?NSFC45y{FbHz8VV#F3t3JSfqLW{{J&3zf6zCC{u2hx3{Cfwc3*<3~Nd`;pY8!M)uK|myh*8 zQut*l9`AZAXaQ4OvGAm;ghmq!u0~sFb5pe^;@MjB#BUZziti?{Kw=){w;om6WP|eP(mzT4!Psi$wBX zte8Hz7aPAY>+T9@@9JP30Zbw9baUD-Q_|ATsNRDNyiuK&p&^U?vI#@fr%$)9E3I~W z%-e&dl0l2rN5nW53=xe_V7uee;C5}1+FxzPUrX*}X{YNqwvDevy!0brHJ34j2}x|w zO*FJ3@aPi&&^N(TDJ)ZC_6|*3{4=9I+NjnxkAOK?i)Pg0n!CrU7QbD=aeA^SH|~zs z5q&lZrq{<`{BxpyUB^aEEGrva_?J>%qmZmJ2z{tRu zsa{%DEQ$iGyYYlYvuT-tTO=uX0*X*9Il6@fg>6@iHfj^){J9^%iu$78du%dXm)gq? znuAg1=&nRJ$6uVrn4^(Vf#4)r`Tha|(&}pZ<*u|@y@!}Z1AnqJR7CSn*clV|>Cn&) z-l1&I{;z-Ao)@G3%6%`EA7)K;@0PPcrpjC^%C!z<7Xx_^wcmyRHVfp-;4ZoFilo#~ zAqsASCzW2L5?{go3{6R@)=LPzF7y>!sHy*QFNc=j!2E?YuL!;71Q_nq{ZGw!8eH|9JK3$*a5f;o15BP;gCK$6J5S{X<}~-IapYEXkH}$ z#-AIj@k#z>CPY5|MNKyt-`Kdsh)F;bB_BTE6ji!0Qc>e;B0-6g)tfU7$>7#olDcV) z4pDL6UC&T3H%kzYx*stG;7cb253w2_pw9fv_QzxG?cW~pk}eAfTOGhedKbwF&n$a$ z&&2q9@={|!pQ8CCG?#k^hWfU+Uqw8%AjS=7J5OM2^!eG5SQnK5L`UZ5LL(!qS++q6 z>WF59of1x0z09tZ$TXSZ=6+YTJRq5RRBC;sTv zjn0TN7uOUyRj?~SqV}zas#DC)yj<(#aK>QLmAHjob0@HCCk?kpuUpzEKf25}mSd#! zmX}1#GyK-ApVllx%7tz%-HF(h=y1Qk@sH7XbPs{RzfgY0c)mg@Hv*^S*V}p&6Q7)% zZMA!YAqt|^6FGd;sX?D~i5T^c-Qr|Uz~Ce*zrf(y6k@l9?v4CHgL_?L1%^xzRq%bV zM-k~;BHQzAjTP|hnEZo+6pSO%iomlDouZ4 zg_5+E>y8%j8<+G033}WsO*XkiBrJFyKd!EHKjxFAGrHLVlH{ixuKSWwyMv@b>$X`Y zuF_QbvsC23qN_%CCXRVGB?K}Mj26HiaA#-$=1wf;%6-{77;BS3pRcW=4%8n=5)ikp zn;v_610@<_EaNr)-0mt!s)Vhy{quwYe zbP;m>TKp~O4k}%CeTrv}{gU{boA;ao^%gC7z@?N@`R>7HcRYmnm@U9M7j?0rm<2{(Aa+Oe51vM3& zj%)XwK5E%qtPGv_J`WW;xuN~}^XSWYHXA>ipfdv87q|0O*`k#)hy-bl%+1}&ci^$^ z-S4BD3C&F4$B^l!Q!RVKLnSLMd$2#~`cW5pp_S8Y-9*nZY>!l&KhbjzC{Hm@sXazBMcy=DL3wu zPmxLf4!pk|g9UMh;atz_yEpC!_|dIPg}^$X_HDm_y=}&OSn%_Sc(kv$QY;=%^@N&DJtG|@6 zij@co)JMM&xHd&-))#l*!M+KG;mS(-4hEY!(aRE!Gw*-wl`dNH`iF&W0*3_BHBkJ4 zCI|NuxI};>@!t2{HiuL7kQKV^%TN|ySBhJ9Oj$(wdUrCFOhj;4m1pU+(t} z3k3n&#Gz0(JGbbfD9;cIGl0soB≫o!5CU6nJtA+Aug#^L-C+(5m3Wfz60>9>dNX z_^|$y4It&a+o&JR2=ziP@CcL6s&%zf4nYTL%nCU&O9 z2Qhlk1##Q81}+7yLEE_1t&h33p2zo#&ncbx3>8ZRn)Li?@Cy#R#cMcLyKc3w!EA52 zMbg>Z%Q0i?pN0>RZ})E`xe%B~3>C3iNU%0bbokB+fmkeqcjd z7`}nWZ-e%HlmQsbVT%50{W2e%2$#wh2f^L@7#;O*UEUaQW2C~41lF|KwHy6^=&Voj zP*6f%?De$YkOO~?@E1HY22-aOA3jPB?IzLt;5Xccv6IAKY*|fTVn9&s7G%K)_T2 z^q-;-lfI4_3lrA{!=UE#{PAC%@>8zLO^%G;cI<{_5P_XGm#coofx+>Lo@QD2(4Zpb zJ&D(;GkXBzpi!OeggE~J-C39Yttiuyf&zJs$q{$f&Prs`M;6>e+FTk)+PlN=tg2%V zcM3RKnT)=|rZjVXj@luY`wKzZUzb*J>C^+33>4?)@|m<9xmfKM?`QCi!j>XP1;i+uBumAHuD5>>wQ(xng`wPv9`Lyj}yJv8)d*woNEC+hv z-Zrb-4O_Yu;g$0JkKjz=b$=E%@+Y@zVBUPmPV&vqCeq#zysxeSKJx3YF^Xb6cpXw%uVb zm@7NtG`_~W(mtT>`fkz{SK0{|t<$#lRdJ&x8kJAZ#QuMx2>$%b|GK66#sAhe&Zm7g zu>V7aRkbB_jqfnPW+{}S}*f9IkukOLR>{@&hjIWFLwl(n?9 zChLvP5pJ}#c`B=@FnldO(6zF)9xXR;hX`|KPXCWfgN61XpKfm70x#2`+rXlyACbZi zE6bk{1P2<){(yHGu&Oy^VFUn*s>cTq6(-&q>Pu7VPH8-XX9kSn1O&LpIr^%H5N)Or zK;O??s{cF3bNl4WZcxk!SWVlS_nK`pQjEy9%S+E~AXUblpcI95>SOJ+BqWW%F3{VK zD=Smgt`?cs5f^WN5OO($76a5yYuceQ$6WB8TAtG#cHWW@JFIo*H=opjOvEh50$};A z-(5eA<8T5YU{AzUum>{#aaFhsrx1;80# zYa9jU-$F<&-WYZ5#m{JtGQN4@@#&-GD>G{=ivW0qbd=YegX z2!KhI_?=tm?7kS^pX#jeK14GyQAq$ID%QZPk9 zF4a;k_n%iD##oGIq(%Vp8zy0(9MeVfPE}?V3DcrOTn+As$Jd_JcwElZD6f{MTYhM4 z207w>mbw1G^N@(1Xm$xKSH*s z)F2UX4+X_w22d{7Ckv~vr#4s|WcMIvS*3u9 zdS!CwhXfvPN|0mqWemfZoQPcy9zS4|%C*xZ&o}MfJLrdkvXVd)AjH3*uxE`5rUKkc z2CYnEYdEBYg{9y3n|i3=-TDcoN}5`(G>l0Z>5Q?R&BSS?d|^y07N0yS0zXJHgwz7p z8wS2WGa8jcV7e)^0nJrRyzjRWv{?C{ji67&Qu_l4@a;&jfY2;1vfs+GjoJ+NSXntm zt+YV96$%D6E+!Hl7NP zi{*8-fay7=nvk5)?y;GgdWTctw_TAX7Z%bemgB|Z15i$n)y7DPfR#=y98CHXkBojTKSw0Olb$)@J&n`GW zinM%ve5|nb46IKOpgHXkBt#5 zd;hJ7nqRg|l#tR9R(0mh<$_^iO-X=M=}X?^Q-W}%C`9X7gpKS)SU zzD|Vie{j?mhf&CVwFI0zf!&pEhx)v(c1|$sMSa9*M!e|D+Ht*#zQQmBj<23hra!_@ zX1OzO*8~ftP%0P$7=9f!D+d=sBzW%Dsbf}Zg8_h@`YH|Ceb_+EfpI=nG}cm(Fz%rGvo;@L7qUjA#=ae+S6PlXj;kiZI<<2!R)VI*YXJapBcA48gq{bG8!NIMEj zlQN?*#g&;oFgox4%aHc`VMmP6J62A}h5b8X(kq;dl9IhEc(;fI{?p@J_^8p6Fs~IU zv;&O?^i`NsDrvOwhXKAgY-fYglnS|xRqnhU4eMEs&prI*2}|PQ8P%Z{I;a{y@$7aJN*OJu95A6rJ=kf*fq1`v zlw7jKWrHx?=4sah$@UMU79|+9$l;;@-(hmnMl&|8>En?KS6tkN+x9t*>-rOIZbt`i z>2>b89v{6v_yN4`&)+boMDmT~!OsHNr^dVrxBYNhlsf>y1s7aFgK6o94=iE-+3&>b zDolp^Bg@TKOXy6jSqNA|ppGNy28WhPNh|ri896sMH}D?8DdCqn3e`hnJv-Q<^oxRu ze)-N%u!FMQpHHxZs9c8a;h)VopI1-*0_){VrQ1K5ztv1yK)?W&T*M!T1O~3dC;~1o zjH~&v^-W>Wi=po8`^7|Qz-{?c%|6Ea?5Ow5*B zvJMCevQ>X*(D#AHYXGjSF?$ug#j`9?90aiWjRPWuebG{|IW#Xxv+VlGZl2jz%azq) z2jtOfiDbxMVuD@m@+ByTJq9inP7L%}K$Z3wQ6vM}#8Q9F({K3pknV!w%m$?rlvu68 ztQZ!G#qhjqZ*8QeejqJpiij0hhkeN=9#>^#WYS9(U+rvC#hCg8C+LHb9o>5v94#u! zN5-DpNb`fc^Xg+kW8fXanizFE3OHJyMfO5WodLQu<$~uUP|LtFQ*_6VG^i{0`k%Iz zKmYRo0e{QbY-e0U6vy^F>o+FYFfQoqlTe4M(0HKTT#;2*uV~hD71Y<)o+u4WZ12*O z=*;^8-~C1mnU`pYi{laCN+b;CoAQTn3v_Fvj#~FF>{g5y*wBN_x3Sh#Y|-625IHz&z3s*@bD)jG_ad9QB2;1=<@0a#TMt&%^~YL z2izp%yFM4)5H@&5VE;fQZ(V`k4RI4roQvI?)6*T%+&nr7GEPo{@eK71g3yLq(mXR! zJ*ayZa_0{5J#uo~DcmF`X(<^QQ$VHx<`!sC?CqBA&3p5^22Sj8o`@bhuf*5M2mrBf;S?pSqOU8KShi__FY2exDk z+JH4cjw+aqoqeTQ5kB!2Tco=E)q+e9#25bkn&^FSb1P%lp_+q<_*6)zSVS3Xna$4> zh&_{%T6E9(8KJM%F`rL>94hW$e^;fcT8WQtPNPZ6JSp3jt0R2E7F0g#vp_4! z`lUW<7^d=RQk;%v7Gk*%C~1lz2x^R=)fCL=hO!+PaSnERL|+am!_{?b`XYdmY*4uU(^ z`!0fY7DgnK`88+lzfC?21H|8m+x!#tckzh^qM=MtOQ{q=&~zLbc&3EUKj&1*a0I#p z(2}328@s`}t)-+@zL&Vn%*?eai*PCmy}$j72?%7ln(ppyI6VQq;Jqz@W7&Q4&wQ>$ zlnPKcmWQ3eGcIZD?S@SC;e5&r*O4u5QttQqD7D0PlywhBTj9#B?-FX|**`U&!83|9 zLEc1)lTJ3tl;Qac;B*ueLn;FjQdsEpz~+4RYztZ9ylv)7a7?oTTl?VZFJrlhZ?^Y8b}$` z3JF}ra{U?z(?;SEbG?tvC@v`(h$eE5;q`SPAjZ0}Xs%@@EDjq|e|5yTgPoSAY&-M* z1#2gs|IqO_)b21)V3cWY$D7f5mL?(A4)~@7y`@9N*wLXYU9L!4$ekV?`eIz3*XSHM zjg~|Y?uV3#E7IKCs@X$l=NZ72Vu*^e9#p1hftrY@;ZzM_Kk<{iBAJ?=zJaA4fJGq@ z_UxPl#3!RU9N#d8{D#tPKs0!7b2?cGOhWoNEQo7v|2NxX1oVj6jSgOWN}=;9s;D?Q zE$R&}!xk@28(kJcPM7DELGgH*mE3pMz6?L#Y98pJZHc;Va6=BY5a(}fCU1-%1OiaL z2ss8jp$g`LOdsTabDRSlTcaSZ)K%ol-m|U6UCdN1vZf86>36do)?f8q_dcyo0ag3h;SZ7=4u zy$7fepw25Rg#&Nwrjs0MlS>8(aLzCb1M>1Zrp|sO@sf%&*->2+2j0Jwf`W5 zp<#P8^J`I!A_wpF5**x?+dFz$O=#^xVK8(#9@L?v<$GWE;V_p4WnamPfRsx9cSqW= zcIYOs2q@}#m1@PX&{q%lug(gzfC20A8>I(48^fW(bCM9LrwF!fi-+ornn9a)Cs%{4!RCC9LW4s%a7`eQMFbJTgao}M- zjka?Y3rSep!TTA!^`-;1R^g>9u*O%;z61M^77gSJZDZqMS%ul{M^7mcvjAB1=hJ;!$3bbJv$pX z=Z#grwcPXCsMG~Qs!}S4mF}%h3^Wj=H(e0C@In9Z)z0cz9+boG1Sco zO)A%I1EHwx^G2FKKznigkbZ=Z$4ykN=y`vGUozb6kUO^=ot3iPR=tiq{UU)OXr{~} zETLcong$r8U3m38En$mfO&)^%$E!%QkrJ%KJ06!@{$=S)b;*yoqz2ts4Go>6hE&+a z)7`KQN8nx0Psy=%41d^M)Wd$+Tnwhd!fk&oYVqp{U^VVkAN@R7bU_A`y*qo)AETk=$J|1a zSXy3y+8faTZ;iq%ccHZXsc#nkLF=T`WJ=@qf2t_!8b>f72a}BnR?CIxmjb9rDPNBT zCe6c#Y(V<+dJnaT^VY#xa5cSTzr7RMNmgiTPXoKKXlb{~$9SE4ZDNC)8e4~^n-|{? zCVgy92@-juBEiR3l|9+oUI8?L%bfTtN<}}9c}JdtD@I6O?k6pJC`QQkSAt`b*J1*Q=4hUOlG#P59%qN%XX5 zR!*Q!-HpfEfs;93klEHIdV7l;3*;@QfcUhb@72|LEQP`SSz5+sW<+7?No zz|PMtf$DDPO|q;C9bCc>EQk33C^W2EXl!gu35J+#JQnw8nfzgE1ookl#LizBgm;Vrflf@TcI9rjprD$NpZ*njMI|`?qdols=AdYV#J*>e zFwCO56FNRfIJ6jq6|dud^i4}C`ElZ_ku;xEE~zc{@i@Av*L~@OWfc=s`0ceJTw(&( zS@|9Rs@IL)kUCPeN2X3lpFu<1h1dHODBVxE%L1=$SL+;`^^&HfZbYjPm_4$2^R4_M zxNAa#!{Lv^3=?C<-XWUT82izCL=3&IqDsVt2G}z&#I#VUK;O!;Q8rX$`Ob*{Hdym` z3`mZBZ`a*cNhy#k`KJXe#AJ)t!cM*9Bj!yAm@Bpp)tOu2%fCRX5!C{KzoQq{4+m<5 zKFH9CfM?_1qNB1@zojcl(hP0uU<)u3!!JE01f@E(w~}Y9P{o>F_tc738ZlgTZ#-1- z_4P86lD&i~d5o(BDX;Gh zZmX-SyVWkRh4prIUAnSnoR%QyhUhadbdO0bC`j0^c#X~nA_auc^tl(^7l&;XZ>kVu ztdPn-5%#}^F8fH55)-@j3O+S@X=!PuQ=2h`UF=h`r(S1cW4&J9dPCynC4KJm(@H;N z8W?41TdHZRziqFtH1y}rwzfu}-6}K?SHxV%QG%XMEP`hn(lAr}@P_hZY_I$X6Y&qB z9!tLPO44K%d5r;#nv98l(!PQ-ko94^Gcj^36ooCw>t;04@~NQ{q9VdxF(kG-VJfdB zPCfE--puPoka)UAY%?s4l&j>=pTpSI|4Kn#Tj$^X4d!$M_KUHd%5+Q{_v8`+>8-`b z+f=k<3#W+#0|TA+?bvw9;fUuhck$kPc`Rq9Ug2^Wp!4!^^HLTk0)3Y~H}TzCcA8vE z@B>+sy{QW!YRe3{IZ1LQmo6PK4pT5PzT0yu(Cu{TMf;RzGIz&_@`XW%_8W)(PY8R% zf~^k-Ua_-h9qFzrj2ttab!<8v9UjRiV9GtCY2H#?kGBw+QnI&&i6%MtX7hryXG}5k z{!KLW<(>j_Bro3FvuL7>(`Gf0X5B3-D}!{?A;t&Euo`zH$JXj31#SpdncvuiRjg|R ztw`_%(}DbuLSptOLh^z$ho$K4A6ejkfViACFes3}bqjx<;ODV2PfRMA(1$>jZMWfL z%?{T+=8SM7Zh0ai9^F{rJj|3kO;OtsW`9Hf5Y%J*bHu zJ{YLCbC8y1*p(C#;=KR8P^FMIe>>nTFEcF4@`1y{5=T=lJ-}A>D%vCdJ82UBC3~&u ztBGhN7MYBQSpT$C{w-u*qQO-xhH~pZo_WDriJ%6U^%pSG(RBUaeP1P}|#(Q6-dgmN3>_aSe=@1<}iG}DX3i{Og_KmP$$sloO}|2oE1 z;*WE9-H9+h|$g1_;Iy!`low^ak+bp+fE}rgQ>Z}aM`ZAAWptfykOMye2->3 zS*2s(ZQ_oh&Jxo>mg;fl7a)sTFWxF!#RL4y9F?I1x%Br`7^Z>2o1_1S})Xrkij z8e7Og0S`{F9ucsZy8 zO>vI_w`qAW1TV^Z9+qN*Y8zJJkWC~eg?+8ZoCemhrpMG=)gj|IS{%z5-uElI4b}j7 zx?Zfb?&~yE<*Tx{8XC!ofA`}7!>vmx6K53)r>T1D>no_?i;AkHwRLS}18Zb z{dRSWy;h)CKoU_P8QHb9#4Ac`D7rlKC3Ey;%(+u&p9#OXypm*K%$=cA-J)G`<*V9G zeM#!Ip+Ga*DIaP>$;GcYc1E69n%weERkk%%jlUGv4-lws=zo;9y) zbDF#eJ|3PbW<_=QI@eZR(W=aDzpXTs=R2LR`8_VRHn$UTw6ZgA5~{qEPe+Ky*w8aD z{3u0!uQ>E5posQQxm|ajwg)^hTpOKmv~Dvi)Gaxo5YOcP7K( zVvdKgn_E>Lo;AQ5HT0h0;mw`dw^LGvjctq2ld2RR*0B4;<#;nRHJR^k72d^-RW8zO zZ^jl$u{~sGFMdt42*2zDsw~@<`ld?fWF^-fe=TEH2FBDbpRbz>@@>(~zKoUnA%rph zY?_+P`jo@v)=}1+S>ffnUn0XvyuN>rkV$Bp$^LknU-e*ZdVMOP5)ExL@I{R4X}4Ed zK3+w$T>3XQzvmg)6PvTXGCy#ixws(n`6Is{XDJ*#4j|%k{rs#sqhFm?T|<^akZ-ML zqU%7mkrI`TjeKj&fk&|GP0}v&fc@@WscL$J_AovCu6K>rLs*dBL}T z`x4gS4&~ULdD<`LOq%W@G;T;^z-Z^fQ8f07lX1mDj=glDWA^L93Xo6UT zc*Gu(myU<>L4if7jXcC#)+y=fv%#+mdT>*w92{p{1>V6QKaL!1?=>IIUUcYpaoTof z)%a3&a(}(8`+R2Kp4oKZ(*giYawt9!3nH$dZASk7)w{a8^#~1{I@*(8xy)QVqWs>d zb%X;meBZ%^xq6Db^1Zq_3=Ay2s`q%d7y4!=$nA-S{SS=$rKfkY`H@{C80hE%JPx6u zp+IpBfnJjYGsGqH{WKE6B(!b?^5T;U-Fvp)Tnq zvqv|uun4BvRK>-Co`nlBZ4+Btn>;Z^eIGVXzo~&qGyMU6{&7c8|eN zqNh_c^eqSj&f5Yrk*NdTYTH;*ef?m+tLq;cp3i|b;mo@nk-(&avn;Spj zVv-hVzGeTxSP5q`37yi4OrZ1I(6B8w`y=VvVP^=@@Veau`uTI?{JGIeBqUvKVlS9X zYN5U0-Rf7El(@zMLJ-_?BLzBr&;tT>w_LaBl~K8O&t5BbZ{i~zk1q`0lT1f)CVytj zOx-@%w>Q6_SN)CnQFjYU<>k{%y2PXoZ%N1ySJVGt@2#V%+}pKLyHHS(5J?eHkS=Ku z0j0Y`x6Gpc>7F#4d%E7W_xtVl+k5Qu{d3M4`#9DgYmGI8 zXFl_J?%#c1_Z9gSF*NG6nvoi;@PDpB&>6D^39sJ#+*}ZJVaeFc{D1wO^N<17kWrA_ zkm#$Elo*MAE36-MIQk`r--5q)p%(vd@5w+;`0sP)ZlRr@;7|N{z{AjgnExLm68(Sa zO5C6SYPyN9t2z+==cd(K9_hcCF%sGNkH(s}pPn8RWas=5;+{))C;t2H|1H-2AF#tf z-=P1ar;D#V8)P)6yIiObITn1q3BP;4+JE46qoSs^Wn_)yc5)2wXi$qvi=RA#12{h3 zId zc%u)=Ns`_6H?|FeJUq(t^1fBX6ja1y6&EXIRdVj1OUHg#&s){sjxc&i%TqHlvhF%t z2TO*y54<=kPheOm9Ox;?#ZOEbF-MIG!ZDR1LAW?vz%)=A8_RRYo}AaY34S-wOcITOV_yH$iy-_x~+-WQtFQ1D))5t%AYWimQ98ylOB`4AtKxGPeI+ zZO*{3c2>Uh+Ak|bue?C4BvRJ$0(j!-o&87uwX2jtF-PT#${+ zf@$E<^c>)G3B-cDjytp9Y^Ph9=`ZFy%H*03Qpd?^e!T26#alNyVxQZkibwUoT;10p z;j6y#-YE$ZR~2%GCYKi^|hN5`jFyPLa%LK=4U5D7N_wr3b_8Y*=t5jhj!V#q-ie8cpC9n<4sQ0;a_@d2yNGjd zmYVv$)vA#BngM*55k0AQzJD)w_3^Ofbr+PG-+f$qZupY4(E=EZx z8Olui!4*?KeTrH&;d#TSS*QZH?a|q?@25|+>OV;5hdjT|D#oti0cE4<0tkf;cb+1D zgeYW`27Ti<5x-SPUR)eK>0DL2KC0(8?B*hgEW$bd^GSJJTV@Fgw!sccSgtoaEzPxU zt{O=h1ozj|(>W{aS<|s(9OFRt#LHd}-h=a(OuJ?}1YKRqeAUfLGtngVsl^*G6ciW=cjJO>mUWu@} z9I@69&`tI(Zo}aM~)QXAhJ8AOyoY0V`m#tc6)v>bpvd);H1s+GP3TKEL z>K=N&kuGVgSxaKh(6YN+^jI=RP^cOrK>ct#REyt8gw_eh@;I5Buz85(sFc{$91r!% zlJMy6kQmBKZGa*&Pbb=Lb(AV|>1<`xX)+#T*^)MzmmOHn{n64L*dW5tZTEFR@hQK4 zUHs>fr+2IAu8ue1;85W%joMimIvGMnBs5BF4$bfwj89W5X5YQ^tK4BbY9Qx_ZANDV zJ01OR0(Pa%#hD{G)?bvba2%YwaKmx5M5)A)8^^OFf`^^Hy3ul$9s z=Gx}2Eg&e5T0egLC}oRBr~JA;9u_@i)63>Y>`EGy4n4`@oOUa@=Bq7`Jptt__;P>N z$j)>8=9WQ&0xlX+&w)w$L1@Ho1irLW|)lu9I(jyR3D1sBip>0uI$h6Eok z3H|P)i8}jHkoPs*qQgzKzL8 z!+O+XSEv993UeG6bD*_T2oq?s@x#5LJKYd4GG)IOyl5m#Nuyc#ktx?aC&lTYyF|a; z^+PU2Qj%)X`rVc^<+%mCOFn9pJ*=!@+w<)Yd!UorBPY3gE4AKh4yy=%?qlm_jwS+G zrW62^ElaaKVO75);(PkAjG&;y((HBJ zj(X8G-52r&bBDIn)ETK68A!t^HAbR4mSCosHJPG6M{-+9N$FK`V}LCy(<*?2H(!VT1@*hSK2mftG z#_rySyuxaqLwx=H0~IfNHrW&$2Eh4P>K|~c>1Vs@)cyM;L$S6M?+{`i{rqfycWHcV zS6v|C16h%JZ-OD?uaqK(3@7IH4W|s|`V_GX3_ zcZoYSKtb50&KzEZQmZ+tf$XoHac41%t-E0uC0{TQlhK;gZ){=VH#@tsV%lV5_`4nK zYduIMVmZKfsKG8A7x-Ltv%WLUYfFGp2jH_mCMA8sM7<1oBb_>$Vo^Ndgp(54x1@&7 z4kFXGu}IOZW|~!QMEs%J!4Ax^a10@`txhbI@87@Z;=*`xxc91nATF{mimh2Pj+?S) zrhy~mI!q6d%tl)#sjpvOXMc+RE~JhRaCKtYp8Edb(uE&XgWo??Xk0uzw}Y+Sju<27T2%W)TT>Ba{IC0%=`Ymc5X1O{Mgx;hp&_P zaf6U|Xt^OgRTrR{0Lz?*JuvZfMj~^q@sPmdQ|Pbr5O~pi+#g8*Dc0EL)&o9R@j{sI zIm>Ea%%?kxh|r|>eH~IBl8cNKYZefdpKp&JjaEA^*}n9njPlQG&6v8i)t;3_V{Eh{ zOjP*iQ;^TOv?g?4#VkDrgVc<=B>?o`yZG+C*-LVGNYvKe-jpBM89G*m^=0|ou^V4+ zFGmIQId>tgj+Oh4rK|7m?K|~25{nXF6eoMTAk{20-6u1oO?UQG;N!KE-lQZwp31h+ zUighlXWD7<_a-XEeVuO$GW+9VD~;t3o-v$GBy@_vo3N?lb9Y*M&FcduF|&J{e}GGg z!35d&({CJO;Io(Nk~v&x#`KPUy9I!VFt9q6L~u}(ZQcrGg!Btv2V%dX`1kyi9TF>h ze8$Grv~gd*o~dUZ&NNEOho!LM8GajlQ7tQLK6WF)Tm0zgYI@qT6fe2ZDd&n(v3is3 zpteUw2rhY^try^r*RG?G$b#UYAoBr#4_|T5+F8d+6ztCmSRpKe~In;$555)dc8yt0wDP!*wG+xf(kg zx9DNbKN21H@DyMdmgvc=PoLY;qhqm+=}>P=)tgyPuYdK4=Je!{jEc%}en+}L;1gI< zC_d8^O9lpg0txS&>4s)qfUDc`8-e!4sd7oGH?|5)`eIrxgXIp^Dblq=iIK}u;Eq2# z`yF7sVDVq+p(;}Gow?sY?B0I=rgx>Q512Yhl@TOWiei-5349DtaN}_&pt-cR!f~Hz zp>GU6tIO()Fj7AlBIh6WWaH6oXD=@;3GGT^{hko6mJI z`PNfUVb?6d&`?2JFa_`n8ZH`0vP-neEt*ryYwh~Nem{IwR2snA-YO)eWbsONI3iRq z3se8i#yY>3OibT~7Ao_>mN-6dq@&=4X=fzROb;|U0K}D2`eDeku-4leM*F?r)B&|J zF~)jH@$c*P#xfY!Io#h2X?0`so}K+0b!EV#OXE*Qz?bEq# z@MS!!upd+LL@NKRuTP~QNxl5YE9viZaQB}PSMz)S5pf-Of{EeBU*Tp?2D?W`x+3-u zzP_(JbRGwZzJ$s9 zAJoct9F{WSe+JJ1+Q`h@i?H206-WXHc6yuQZRq15w@yV)4$kZ<+Ywp}Y9A~T#-W+qNRz|Gaw%;uecg=`5AC_v(`JtQ+vRb_f9 zvjZ4HFNB3vg@vOWv7v$~FyP-@a@+sUaoo1Qa9jrx8YU)_(^@?^1@P`UR}tXd6{U!W1XajSw|Z`hRh;tTxg>2SI@$~|j6NN3_4tfggQ%J}gK z=C0`DG96FZg7k@EOQ%zhr06kv6%}wtWEToh<8YmB>o+@lJASw_R5uGH7ACWtnkW|g z4l%NEo%82XDPg)xZjW%<+e1xpH8s5=87yk;wxTzg_rbbfXh#m9>%TEXeHwRRQ`=k3Sr+%ZFO;$^to8#`&{q_1wj zd@&t$*rojl-nFZ!b>rdurvnCnv`Z55w%so{Q~4^C_hBRnnQ1NXR9jmcjHsthG;>t? zJUwzOD;y3{W-ST7DSHL7FX94M8y_d;kTy#nJs5cIkdb@Fffdu{MbGR!J?Nz87pI0x zLM-(PbafNi&MGQfz<}i%H@CX+L=fimD*0-NolC3eE06%M8)2uz@bl^pdier-rk4MM0;}#{x}VSB88D{ z4tTnZnwlE&REMdD7}AA+2S;HzsLir$xjr&*T;nOcn^NEO&2^ze^HZ@C9Ro*eDP(7XCS>=ayv2tm8%b|!lIN2@1C;60z3`UHVMNJP^2H#ND(ZEq>7R}@C7WgQimY<6tyG$E2abAH#D3XyL*CXeT=Ncj}t3yhFmC z2Mfz$3H>HOGxyvNmbL}!gC80#;mze(wZU8&F?!RLXO|784oe@P5nv6jm_94JPUOSt!JXef5orKN+?jay^6#U6_j z`UG-~-!9&kf&Db+%I6$SzBc57^3V_tj@PFj!QRQ<0R@6|$XMh8?cwQ3BgH_E*>HYx z`e@l1q}yKx)Md(%PZCRUTO(O5&T(VoSDW&-wUrfh8NPRMG3-v?^9!p!Xq~Dff?oUu zH>mo17MbSt^z|&W(0Cg;c~5$1vryF)ISGSp0D4sXmhGf7I4d?QRx%x9*h9k1*0X>`j2+znP-MRjY5 zDQ->8&1U_Xh#x<+qgb=9Vi9x|1s)9BM#b{FSx)bN0}09XxnK|qDZzzkYnGS0ooNAp z2vI$th7M?hXY`vb`S$kI?xF4IS0iLkcXwKJk%jDdrMTtZh6d7jD4Lz=w70$+>%yK$ zQi@vyr`v0r9qj%#ICWqb2Qe3tb81%GYGwNDyBmkQs-g9O2dS06EBQgc(-5G__1$iZ zjtmOB-C)d!mObQ3J1x!Lp~)(ol`S&&QN%3>xwBey=5)fbcKkuK&WC;F*gzDzHPd>T ziKI>Jf)Fy-x38Yy6l0_|&^+FcqOgIAmN8%s;jFh?e!+7B$MeZ3WbPg&Z}s;5%w+2bu2m$Qozp|zylc3Okw_% zj&9$CND6`8bbP9=N0}E5`rxJLF$$G zj}HQunm*(a-kmVl8Qq*o3AZ=6Nu@EuBblXv6|jY0=df4&qhfE z%jaRimoJ;V!25r}Kv>AJ`_MKcwRUya##cpl42!Nivfu%rAaZgxi@}!}I*Ah>Rc+Z+ z#b;#+;wlxn_M}K63hamSy{lu&Y?seEn)Y8MAMLKLE>00VzzzlQLqh2{5cd+3l2U_B z$1>(C#55a(LaNF%V)2GBRb^zRrJJAMfbyHiAPwtrFc%JCN1<3V-LAj(rGvLo48ir@ z8P${0re3xeVuT%%-5Dzc1c}|{k*K!3T>O{vVv?f%pFcgfJf`0ra9Y=xy*$uribqOG zxv*@F*!33;e?}SyR;;@1K(+8Pu`ly|t(e}HiI8n>127@2jcDX-K2v@M7yG#VZgzC^ zXbk7xoq}~J^NrHLzPQ`$qh4k=UbGkIbcZc?qWoRRZKq}ZF1S7DCa*>DYxvpw4CrG| z;oD%>F7||oU6>r6hEPU7cYTM-G;;1IV%Lr+b)@zn4SN>bS-^*m6b1+mqO~hDD(GO= z#wxLk`IGztx3)z@1e%%odHpr>g{Ht2507;x6mjMWlhlpDqdhDQP4VFXW8Vr@-B*Q4f*6xl8XTVP~`e0${UrNzRL9|v@l zDM7jt@YM6-z`7d-HDp2F%^N$wMdL;++Ld^ROJ^8&Y1cX;FBLhoeunX!kz2c1U^T|D zwVwzwS>Dz}yHm$XK!A%;0jR{j1{?Q(s~BHV?lGUZ+25;sip0HBAIE-2zFb+&^mTmvjLSpp`Da4f#|Jelx*-cE=z4mZ zUdlEn*DkpxW5VR%fcKBfZiwy;(1k|drV&+@G143p66LhfB`v!AZFR?g*#0-y&u-Td z)R>y^zJudIu%;YL#b+v2k&O(1s`dBxLw;*4i-{wP=50`Sgr2QiGD5JpiE=9u+p*ib zAgEbCkcg0W4*U5Z4KeA)qDzsQ6~^vN_LqICV=$EOBWgSmez9x&2J7=QoKVKKsCCTE zaUWpYo~^aFgyAI$xxk~r!gSI%!)fP_jld=(Y|$aY$%fQ3_{rtvC(kko!HdS2m3Rga z%xpY_zzFPFUS4bVUzCOF0P`PiTU>itVpc#XNatXX{OwSqD)z)8%pEEgEOaE38$Vm7 zo7RXE_n#FwH1axW>u9>3os!e>o`F0XhBKyB7I*6isoC`?tO`ejjHgzyF>KJ+VwO8& zU)oKfI`2RnU@QP>OWRH39m$1~jb}c1i`xrsicyBYUc5KCPeCEbC_UGGF918tb9QZA zd|5|Z8;P_A!-PO$PTQTqr_h(h_y_p=-z>FR>>W#du>4h6!eaapn4L$91wLpcteRpr z{`uJr1ydM+(eE4&WO~@Bv4QE=PB)d9Gof`_PIW=ggH%Mc70>~2U|5-26c~%Ud*_bw z4?|+IN6?@8ZnojIf{WSq#|^1(MG@}s;eY%DA z{gXzJFfulInR60p#egd#fs%b0&;+sq^kUR54q(yASiZgQBh+iu`uH1Y6WA<1td7|mj_#)&UIC#{l-0V1u3o$2&{zms({D^8<8UzF z-;e7N7)JJ#`YBkJ9dZ&8ILeBv3&TIi0F7|-3jKEFCW3-9q>iY(ZuT8U%p z(3hkxd2L*q#T9vJXL5?Xiu%0n@Xy3$APDW}P}9;DI`%O_(zz4oa+$rk>Ap!oUXT{- z5?~J%6D{QEq#pd`3+aOeU>3+_wP@6i6g_o0RQ@v2|J=Z3Vae1LJ@$gGtPB2;4fWCnKLi zmz7%OUW;a!VV&A3ABK2!YqK~ZFG;JY6zaEMVWa{2vWbK-%feAdG=tu}|5LTNr7jJN zL3$^vQC{1k*)vM*)Ak=UPwt)U8_C?=K4XFF`CarhO?~bIYaR?6ORLZCLvLEp_hpz< z`W8=lDn>$gQm8+{lLPJ--b({l6@FVTglGEuBa8y!OOEE5+oEUmk97ZSwR1_eXz$-} zy^Ox=EpO=IITVYgM+yCwS683kp9dC6uYKDGZKXW(>e)3riGw}HkI;)|D&>Wx8hDLs zA6F@RY;0Bpf7$mIgZQOegt`K|Z%2lh28KawpZj6#wy4uqnL>fhYOOz#*Y)@%jYC>$ zQ~B<}sw%OGRtT8U_*SU_L9NIrOL+|KbwKcKzJ7OYEW^RtRq+EJM>dJZqst%Mt~$sN z*_o!Yr*YZITeQL9YYFs~&sWB-U>0VX3Lzm-W^HVw$p+Y9135Fw!ip1>L>-bnU_$HU zc+fw|Ei3hfH`fgh0ZEB&JHHv$w*XJQdihWaBmLdG!)VUV(bIUI+qw0Z9FUiOo^|s9 zb=_6@L@x{f<`o3=pYf-A(wlq8h%>Qk{oTe7?hv<>*3huX$BP=wq+XrE$HqF><<1@G z!f!jv-aGspK>&qYrBLo$?IxvUtYv?DTg&&t7i{!hIVXNeB$w9I2%rDn+Fc$fE`Ddc z`BI`==8RSj(ilaP;Or9-bvub{X=y5V3$G@4CC%&Smh)R?MqLgdp0w9rK!+h8X3;0o zKC|Tjz30{xY%b6+;nLOfs$yT*mIE1>d)d0ymBv(jtID?L=AvZnZ9avGYG0*vlZYDKdZq5VS!MSi0JiIHme zA;cjtM1TH##5h(&$J9UDnnXTwLnWdgEjLzO0k0fw~3@FNJyYa;=^_uE;sR(1wQ?_-fE=!WJJu zDddm)Lbra$G3s5AmzI&Sn$a|AvQbevge||xfLXcqf(C5fq@|@TEiJ9KMgToSty+*x z{t!glMM%aqNCo8!u~q8k@Bv=sv%8Jis(a(Tx2eYy6C)jp%RrUtycv9? z`JnLl_#h<9{qtv32~%T!{%|lBoB6QgRxf)84us_OXcXDa{g^YMipr2iz!70SQo0B! zNf55n+BVCm-%uXDm?le`sae_RA4BN6K)c&j9iLDY=L0+y(7ipDMAUZ@o%{E}nFR@N z4k}uZY#@6(br4`Q1XJVi@?x;)&ufF)=M}l;W@n8z^zRhR|ASrWu-;i6KXZVjRN@>% zU)J=jvg>Ddm*H9Q2SXNtrelT6A6qSXa?%0EF%RG80E>#_`v(V?d-U7D`oJ}mLQgjl zYh^^wh&@6WQN?Bf`Pn3Ht4i4yuAbesltWTN+E;$!>4cE+aG7okSxM#+Djf zJ2g+-?tYpF>}(bn?!vx2zW>csdzTb%^@QKeOYpKk*O9U^6yBLh=@qcIx5!?83+!R% zUE{a538-HQL~Ld%XD00RBU7O#=K-0RbW!0j=@{qcRGQg`p>|>Rm;eRHT|lQLCrHdO zFtsMm0px5^o?y3i59=XftlFbU3FNBnIMKv@VX+DVz*i!|C_ELHu{Nmv$Ub?CW~ozxAxnD z?{*%fJ3BVYy#6)d4vFanu6w+6B)WPh(%rp(yY2tb9Dc4HLJJR zac`|&@-_4_#>yToZIKM};^Ok~_Gp^bR-VG3jf>w=+E(bVTYv$d3LqmB6SRun1)WuMB@zlmVJhGD#E5R!X ztQ`Q-X-@Q=xp|Z0^!-vm*sIOX99x%LyDBi&_EmoYoU6Ns7aD0+lYTK}UaZ;u0=}Q*9M9S;q+Ldnn7{H@n-4ct8NDlMLMEQRaxdIMJ~7R|*(OB>r_)F-l9b3$)v zNo%&eI9F!4d1L9@$B#{;>`Vih8VX3olfO~TlOu_|yEr(ZK|%0W+7ng7r4zitoTIG( z99Ph_d6%B5v_+AMlG1*^D;Dj|&YtfCd(b81>}-)}c#Z>fTLT8bxePYf`$*R=yL|nofB+)5BHDup1-*c{xKZE9ES$+6Oi(Cj! zRs{vp=Z~0~n0k8ZJ)UtnTnHq1(^30s`D^4N|N+m9)O{=yW2i7I4q?SoWtdFlDRn62II zIA*=a3v_Rz`XimkU%);Mpv?GP*vv~=w8vI_`BY9Mj9G^3OhIuVZz}c`)=cUgOcn?p z2LhzU*`29=oKZA1nXCF=L6dS7K?1N~1-S~4;_4|M1!I-jt(JpIY44%=qbi5Z$7HDf z_IMCCpp`YDId2x1Sl9Iq8Cf~Vxu24AH@9v-90>wC1{aqLl@n7Kt@4-Huvh&_l_t=S zh|TLJQkD>oH`-?Ux!XdA0#VPp0G~q1ZjR=^cqEIX>;?=l*`a#uOIq69p;YW9^Ky(x zaT9SWYrt>VO|ExqC;r!%sh3kYpRDW&zwRJZTP3C$?7J3YmgOv)WAUriXB+*OoG8|Z7TplU4 zX}+)pZa;$k$Hj`0N@XL9`}*h4^-A8kv!@-~7`zCgabj3eH8t@;$(PO(gr9tp^A7X( zM7T5R_=6u{UA5`d#US(XQ7e;32I@sQ{6IYlitE>}TU#^P7g^6%ZT0& zy_ED3e2NR%?>GVofG&lO$P!6ch9d@PAYSIeq$DdF^Nd{PwPMjf2zhdew$1GqtL#G4 z_|Z!ob&LxBWZ^(Nn=+gJ^6)f3$U%Y!x)%t1S(up<3g9syN}#h5R+Gr(ao(kZ1;Q&= zsvFCDF99!+r)GgIN<3d2I7fyc%)?EV_X7ISYsj%reg@w6Z`{~|=0V>{K}^gPhz`0O zz(v`0Yc#kaqxcq&R_!4+oQ&3IocI;;)CI~+DM3bb7F7U{ep9>(gb*-zx3BmukqmW5 zAzP`I#r;cA5HY(pK0?7PtyYkS z2@oh!RYCX!(+_lw0P~d^^(04MM(>G|wzs!F$Sc0pn!2M~YdQP2gYzIE1`)^e7ee_# zg9zdfws*EDbTB}zSk<31ze&w#o{20h41?J=Q@MvdZWrqs=&TY&A`z(gD;DbIKaaKf zF#1{R`g&~@GLKoRbvEMP&vJ+}B}+nEBXiz4^c&au1r`$qAmhn#Fm9b6V8objmyt95 zRcQ8rDjMhE{rg7V&#i;^?u5_iJ|v%+>8mDXq@kOL7T#4kNYmfKg__kT*0SgqVEtVJWQ->~x(dLiI4MzgI94h|~;EhdC~>qDP52z5NKVv+W6o(`1(P7WKb!4}Z` z)i=Bxt8zlJhT(mMDuzUo^?dur1_HqA9?u<7^-n_qL}28?vu6iH$ZU1w_ZA6;Z@rgGtMTpa zLfdDZj#!f?ZTgK;A>p;S~vw62z1h+R^t#Mosui~f}@S^^%;;qqCm$ZW)$;C^kIf}7-Ue*ezf zDn45u4C#yS&=-~h9{seMd11p6mXPXhtuJ|Y(e}Mit~Hgg-*KgIUEv)g81ZH|^s=GZ znMh8w3q50r-S5w$H(vWtACz34k*p%XC~5Lp{-2)#rW{08BHhU zS6u8ncNo_7MAk+T;c;g`fd+eOZpWQ{ShGS$>p`kUr=cKkR$@3cI{fbX$(j+(RxPZIibp0EKQnOY%xdt>7<$)}GWk1LGEfbecoW^OAzqvX0f z^VO21<%R75lGdF3>k>$JXz$@5kPja9DdpkmVpnJ&JYHCglgupb3 zLxN)GAyIMl6kv$v=kE+>aK^h$NHhq|4nO58Y`gcROI1uksG%*tvF*OBM4Xia{{8+E zY-?0*>o4qrBH9)I`?5+TtQO;=M!g%uu6>yg=u66CWKS!+rlB5CD;#saSNcfMfNnzE z0ALCr;zWEZr+D<}GYRKUgIStaV+2xC`}X%3`h4rWBjsA|vtCnzdTPyIKhdcj(X;*v z-ri>4&|a+ND@oE=wapBhfF=~N+jY&RgWl!K)AS>()8WSFOw<`y>gF=!*`7DCL8n1R z>#b(NLd)|FB+&#}yDtPR?$(mN@l&sI%}C%cdhcmQ9pwt&X}QxLE2Fy9#>uV;nS~(# zeV#TA&?-7U*E%+Q{d)crBHDOnFHw?YJD!ikm;o0{lEhf+yp|C?`lRCy0(MmY_~y9F z{EZSg@HB|CVbUv5FVaWDhtrE__z<@V4IehL(s$vR+X89$L%=O-kFgNS&e+Dv)HMrV zkZG4-#%KDs9ob*ce9yBhU}p5Vi8j6W+9biv%eaYai#-caKVad5BH70|!H}rN<$T*U zw>*#o_;>GxkI{)qu4doF!26QI_^M}31c%;*&{!^qC!mbF1Dj_U83A^>}6A1f3wQNp_`V1w2z(dQ!wcU&9KxhE;)Q>G>(P zB)wt+`!Dzr_9Ref%`(Rl$Pbp;=Tx7|0SKT#JzSQL?@OAp z+Zd_=t}e&Qe>(B?Ow&uD24NuAE5$)T&B;cPj_&YyN2$a1{EHk+kORCH5ivhDxrLAr z6(xDzqz|#|aBzSC8URYLB9V}Ab=vA3syv~2KG6OS&GXZ~i$Ke+E8qT+T{9z9H>c}` zDrgfYX@uIzI{pQAq=6p=EcEn%v1^*V^Hfh(hmArwtNQOkCkIi;@XxJV8mjhvsP)(2 z(*{rke=>=XpsZi@wbb_&%ID76CP7yFOmY^?U&)WPCGdIYgO~3Ec-*fIY)p0WHat3Y zip)|qJ$?Ol&EgdpP@pSRyeNQL<8CS|&kF)67z<*&!08M+19(vY(FAP+2S)(8qCgn< z(-zuDo5@`bnvP?MBN=ix8!Pl1uB;RRT3%sc;l4>-7{EtAxRd@x5A7~eW;YD*oMa47 zmBpD>VV`L_vwcTffl1iz~DH0`3ElpDFIt>z-Ro3B-hHtulO>_!ClsRa@LQ>kMQ!q5) zOxyUzeevo}!aBMNVZqdbiQjZn( z-}#JPP_UQBVG-%MaLB96Dz&gF1e@~L10kM!b@TR@V zXnC}YFAiK5{&(@S?wGl^_Fw8}(2{Y5oy?1WDLpydojTl;rD>m=g<7&f+XMYZuYXBl zk(3H#KV056Xl;eadOZRn9LqOnv{$c=falB;>Zc4p>-F?>0fM%(B5+uoMwrwna^w$? zd(|`y2eXhS!$Y!a-W}@r`?*y_@$+!$NwtZ`JembS@S>oYw@NZ?b3zy!o*Fny00n%x zKP!I2-Yi{00*7N5QZ=+vZ=yk}bGPCfAs__Msw@9C}AJgMHNy=J&sj6W|i67M@z~45on*ap8r21zcKT6t@EblXd?wtXx`Xp`O5s*8&Q7 ze}mQjTOVjba(Oua5G~J^Do=pTriM=5=HhmFxF+Pf{{o9OHymrw2zFExUGn+H7%C!pMsND_ zHzWmVmRPaust*G&27mryTvi$HYE~R!Ivi}-0^&@;Ci%npO&80bBur?Oem6>jP3y{9 zJ=$&K4&mLy-H{^5irf4+++5`azaHj@uxF}fMl9gJb?-Ya^v>;3RW@C3MnEP8yN-pq zIq1p2(1w`sE(fSWT~BE0)<<#H?&oM$$t$DLP;iTN=-u7enYshNz;dpoFOJvmQW+n7 zWEDjS(REzCl5KfG5x%1sVM*P9VP$9*upTgQ(DBCY&j4o=2#dn(m}lE5<~N}q|BGd5 zZLyeO0PskEpfYk{bF52Ehh&fdtPPlWj%X#g(W=X0kh$`vk8B|*TBm^ zJn9H>JN@63$ka!TL6CJ~e%uQ|s2H1xVAvuj^(ALz=!*&4yK*tm$-NYGIKhOp(t-Rm zuj&Ed;YgKrXcq5m&6%9GLc>7{sx1UDYwxMX&wyDrIuB20^twp@F=dr{)v3jIRwC{n zydbh!T~IeZURA9D9#HgL+LKq};-N_Ge_~^r>`oiqc+Y9S+7C}w>&b^Q^&&k zn2O?{FDAEZt`#@$%5OP;6Fv4v45tSGuQ&TFEoC)L)B>9bp5{yZnVLmQbw_OQWU8`R zkhXsG^#y1@hTA0*cJV+))7JS8^k6Y3JIc6%q9UymGYJvrJ9eD9Ewk5Wk#RhFYr_?B z+b!Z^r84sR?a?Cog+^Jg_0|SEPCB16F||7EcdgNNAFU4Byo6WiGh4fs4DjR88A8eKF2NAG9nmXQS!t1Z><ngu*F>*WRCjg`jj8q&P)mN`x!Gf^j-7hW@IyOto`P@a7$m5zcWv18M zV|J>nXVA(AgRt{D&h#e8ixFWB{={bS~#)d-&USy}H7e2zs zR4q#TF=FlU)_(6H*y|<0%2)R=ILfC>5>im4+)T%RcoOt80>v_Pjzp06bTi?L7#*;E zR)?zCvdNEzp}7al+1F73c5}b#IN>|f5el4kMnMM+;ul~ORIHLzaX7-1Ru2Fo(3f=` zSMUuwF2K{n?Svxj0hjilae`x9E$t_5PF`$h=A*^pw>08dZM%Vu`@HH){yp!MFuXeyLb$qi!HG$}d(IRR1qB;f#z70T*Q8$711eckGScduQ&| zwJ-F~YW^Zmf?9u&77LWUHSMp*UL`|0us1zZ^;#IE3Ep4F8HYRAQfxaFsz}eIS?L%9 zd7izWqen+QeLkzGV40;D^i~QikcFW}3fE6T?yoPrea0=33-H-VkXeP?`2uXVY@r2AX|ISZi;-xK3 zuR_!pL4{WN+cB~<HF83V)R7RNYfH^Oq1RMpfV45ygDrpV9NcW79tf4J`# zF)$!ZveKrOSNm7bTAv4_)tKWqM!-=EthaV=m3}5-n}hwp_cdtSVZgsJCj|;(HJ|J9 z$w^e(R}^SFKP+U-{ou4_cLw+|3kzV<(cxhaF`ay9oSEsPSj|!z@-kJCFC-*BcYXqy zBZ9%@1_Bc!FoH*C97up@+KYOb*1}{20#UEBzips(Wes@P(yDwSuUVqlbwpm}PK+dm z)9EDatqzd^4DM`P*U;WK{PyuA71LWbmep^GiBEoFn7@G$N8~?Zhcn23#tym9oG7j- zD!uc{sz~VCQH-0JeY_-ZDu)FaJ>pTU@={;ytq2xZZC}5b9zZ<>0km*n%=C7|zq1~dB)#!2 zt(Aj2L&P5ASfQ=#%XfyO%PHd;;jG664WNTM5Pf~yYq`D^B-ytMJk5yq?4MdCCN@q$XzzocQCj zdsBH!fCrEvKJ=9RnPAlZ@E?pv#c+7>vKJ(!Ww(?7ROvu4C8+FmXP`NdzMGwaMBGj6 zAMyp9DzxfWnr)Qcy|is?rvF)=mF0bHSNj3MrM2VW&>#k;RR-&ZuRSShFjPwS0FNp5 z%_<5PJ3}aBYkW84mS+v@JsB)(2f^Xb*4DPE?GL5V$mv7?_wR&@HrgTKxmpWHK&+Kr(lZsTrpa!+ZJp`KFVk57HZ3I$}#y zG5BwTMT)o5uwsA8V;UOU(-mK9gU?vR%<+O4m%$VxEse)vYabK~F9jpc15-01)|_)1 zYiT$M8?nC++LvNhi(dcA@d_sviw;85i)x@@p9X;q(XG;3gr=%P&X^D;HC9#>v(} z;UZ4pBhadt<8Tk3QFrpdno@(yZHJa(O-pybzx~0dPkx=z+>$&aMUv~dH1|I8epHEC z8;}WgzO)9e2U6-^F!3p6A?D_vIgh+rXwG9cRY=s)I-xib(R}v|aP5*<&*@k%bZ|3j zz3B={y@NI(ydkR4?=~Obd$V9;8n*Hy)Dy~2*$-&$p9W)S2b*x-XacF^@nceI^6gXg z3(LPrm(Yr6kM-{&?Lw&;M%Q7e>C-bzwTRaYFeGE!+wM(6rn$(ch{x>sj6|!r$Y@lu z+w#W6t)XB@P*S>?K9`Ze4Ogmrius{4);d7))$B7V1qD=RhfA-3_^o-X^h^9Zi(`IE z!kUH7w-#o90o6{XPTCtw@AJis?3I=1@^UUR^D$#BhSa@vi9P5`*T?KXBE__|gSR7I zU%p&rj`t4<^v3qxOH)&SNuYoJjG+FBTpVeP76a#SUjI+HEe>$REVZDil6G>?3##AR zIJMW@?#lQ^c6y8@<3@Z`>-zhNaaFjbPRtQX&((eF_U+rc#{HT_6`LSbbD(pygxfgN z!A<1NZYlAe0#H8n(+X-ogi$AzB#7khyk&_H!@}=UR1ivTp$Z^`cA&-0;zd|YjE;LFIh7 zz0eu4kcWXwJCbWjePFsE(SSnrjcCWidIF>XkOqiN*-ly(0Nnt{ki@UmomQL~8m5LU zjyYl8BwBL@DlZHiz&wP<&|<8xwYKH`p}<(aWd&f7lawz108r;QJCj7iJ9=vm@aVMh zCfLk-*xnZ0k&5azDmC3*SDt>3v&Q=l))X&qRl$7u1*{M6cT64Mcn0UVi= z!$#uL@@DsK`h09TP@X=4`t2HgG}*N&fAJrE9hKD7xSTq^g9D#~oKcU;b{j$mh9&km zAd$?^a!s8$X;`zt{Zh&5mP%?BR6mc7&TxPXuaZZDv(Lc?xZ4`P`wfMd$7%2A5%7*Gkso z3WuY-nUv+q)Q#Xuu^q>>>_3O1NH*iS0 zboKBD&3BubhO8x_)dXQq^WpqhmIX9vQNVza!|isn3;zx%z%0D3+$5Qo8u~7m#lq4N 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 literal 0 HcmV?d00001 diff --git a/SavelyTests/Fixtures/Receipts/README.md b/SavelyTests/Fixtures/Receipts/README.md new file mode 100644 index 0000000..ff79c0c --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/README.md @@ -0,0 +1,25 @@ +# Receipt parser fixtures + +Each `*.json` is one OCR dump run through `ReceiptParser` by +`ReceiptFixtureTests`: + +```json +{ + "id": "oxxo-cash", + "source": "hand-authored OXXO-style thermal ticket (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [{ "text": "TOTAL", "box": { "x": 0.05, "y": 0.37, "width": 0.45, "height": 0.022 }, "confidence": 1 }], + "expected": { "total": 41.18, "alternativesInclude": [35.5], "merchant": "OXXO", + "date": "2026-08-14", "category": "Food", "confidence": "high" } +} +``` + +- `box` is normalized, origin top-left (what `ReceiptOCR` produces). +- Every `expected` key is optional; only the keys present are asserted. +- **`source` must say where the fixture came from.** The current set is + hand-authored (layouts written to pin behaviour) — it proves rules, not + real-world accuracy. +- Real receipts: use the DEBUG-only "Export OCR dump" button on the review + screen, then **strip PII before committing** (RFC, phone, address, card + digits, names, ticket/folio ids). JSON only — never commit receipt images. diff --git a/SavelyTests/Fixtures/Receipts/coffee-under-ten.json b/SavelyTests/Fixtures/Receipts/coffee-under-ten.json new file mode 100644 index 0000000..ebd9cb4 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/coffee-under-ten.json @@ -0,0 +1,114 @@ +{ + "id": "coffee-under-ten", + "source": "hand-authored coffee receipt with a total under 10 and no decimals on one line (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "STARBUCKS COFFEE", + "box": { + "x": 0.2, + "width": 0.6, + "height": 0.03, + "y": 0.02 + }, + "confidence": 1 + }, + { + "text": "PLAZA RIO TIJUANA", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CAFE AMERICANO TALL", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "8.50", + "box": { + "x": 0.7, + "y": 0.085, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "8.50", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "EFECTIVO", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$10", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CAMBIO", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$1.50", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 8.5, + "merchant": "Starbucks", + "category": "Coffee", + "confidence": "high" + } +} diff --git a/SavelyTests/Fixtures/Receipts/column-split.json b/SavelyTests/Fixtures/Receipts/column-split.json new file mode 100644 index 0000000..ba99c76 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/column-split.json @@ -0,0 +1,147 @@ +{ + "id": "column-split", + "source": "hand-authored: Vision returned the label column and the amount column as separate observations on the same rows (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "ABARROTES DON PEPE", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CAFE SOLUBLE", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "35.50", + "box": { + "x": 0.7, + "y": 0.053000000000000005, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "SUBTOTAL", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "35.50", + "box": { + "x": 0.7, + "y": 0.085, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "IVA", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "5.68", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "41.18", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "EFECTIVO", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "50.00", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CAMBIO", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "8.82", + "box": { + "x": 0.7, + "y": 0.21300000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 41.18, + "alternativesInclude": [ + 35.5 + ], + "merchant": "Abarrotes Don Pepe", + "category": "Food", + "confidence": "high" + } +} diff --git a/SavelyTests/Fixtures/Receipts/comma-decimal.json b/SavelyTests/Fixtures/Receipts/comma-decimal.json new file mode 100644 index 0000000..5cd6103 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/comma-decimal.json @@ -0,0 +1,143 @@ +{ + "id": "comma-decimal", + "source": "hand-authored es-ES style receipt using comma decimals and dot thousands (not a real receipt)", + "locale": "es_ES", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "MERCADO CENTRAL", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "JAMON IBERICO", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "1.199,00", + "box": { + "x": 0.7, + "y": 0.053000000000000005, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "QUESO MANCHEGO", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "35,56", + "box": { + "x": 0.7, + "y": 0.085, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "SUBTOTAL", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "1.234,56", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "IVA", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "0,00", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "1.234,56", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TARJETA", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "1.234,56", + "box": { + "x": 0.7, + "y": 0.21300000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 1234.56, + "merchant": "Mercado Central", + "confidence": "high" + } +} diff --git a/SavelyTests/Fixtures/Receipts/costco-no-separators.json b/SavelyTests/Fixtures/Receipts/costco-no-separators.json new file mode 100644 index 0000000..c5f138d --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/costco-no-separators.json @@ -0,0 +1,178 @@ +{ + "id": "costco-no-separators", + "source": "hand-authored warehouse ticket with 5-digit amounts and no thousands separators (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "COSTCO WHOLESALE", + "box": { + "x": 0.2, + "width": 0.6, + "height": 0.034, + "y": 0.02 + }, + "confidence": 1 + }, + { + "text": "IMPORTADORA PRIMEX SA DE CV", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TIJUANA #123", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TV 65 PULGADAS", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "11999.00", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CABLE HDMI", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "499.00", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "SUBTOTAL", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "12499.00", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "IVA", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "1999.84", + "box": { + "x": 0.7, + "y": 0.21300000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.24400000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "14498.84", + "box": { + "x": 0.7, + "y": 0.24500000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TARJETA", + "box": { + "x": 0.05, + "y": 0.276, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "14498.84", + "box": { + "x": 0.7, + "y": 0.277, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "16/08/26 11:02", + "box": { + "x": 0.05, + "y": 0.30800000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 14498.84, + "alternativesInclude": [ + 12499.0 + ], + "merchant": "Costco", + "date": "2026-08-16", + "category": "Food", + "confidence": "high" + } +} diff --git a/SavelyTests/Fixtures/Receipts/dual-currency-mx-locale.json b/SavelyTests/Fixtures/Receipts/dual-currency-mx-locale.json new file mode 100644 index 0000000..b535421 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/dual-currency-mx-locale.json @@ -0,0 +1,137 @@ +{ + "id": "dual-currency-mx-locale", + "source": "hand-authored border receipt printing both currencies; device locale es_MX (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "FARMACIA SAN PABLO", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "AV. INTERNACIONAL 9", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "PARACETAMOL", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "350.00", + "box": { + "x": 0.7, + "y": 0.085, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL MXN", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "350.00", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL USD", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "19.50", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TIPO DE CAMBIO", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "17.95", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "PAGO USD", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "20.00", + "box": { + "x": 0.7, + "y": 0.21300000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 350.0, + "alternativesInclude": [ + 19.5 + ], + "merchant": "Farmacia San Pablo", + "category": "Shopping", + "currencyHint": "MXN" + } +} diff --git a/SavelyTests/Fixtures/Receipts/dual-currency-us-locale.json b/SavelyTests/Fixtures/Receipts/dual-currency-us-locale.json new file mode 100644 index 0000000..29bb667 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/dual-currency-us-locale.json @@ -0,0 +1,135 @@ +{ + "id": "dual-currency-us-locale", + "source": "same layout as dual-currency-mx-locale but the device locale is en_US (not a real receipt)", + "locale": "en_US", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "FARMACIA SAN PABLO", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "AV. INTERNACIONAL 9", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "PARACETAMOL", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "350.00", + "box": { + "x": 0.7, + "y": 0.085, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL MXN", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "350.00", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL USD", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "19.50", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TIPO DE CAMBIO", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "17.95", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "PAGO USD", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "20.00", + "box": { + "x": 0.7, + "y": 0.21300000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 19.5, + "alternativesInclude": [ + 350.0 + ], + "currencyHint": "USD" + } +} diff --git a/SavelyTests/Fixtures/Receipts/label-above-amount.json b/SavelyTests/Fixtures/Receipts/label-above-amount.json new file mode 100644 index 0000000..b4d15c3 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/label-above-amount.json @@ -0,0 +1,143 @@ +{ + "id": "label-above-amount", + "source": "hand-authored: TOTAL printed on its own row, amount right-aligned on the next row (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "PANADERIA LA ESPIGA", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CONCHAS X3", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "36.00", + "box": { + "x": 0.7, + "y": 0.053000000000000005, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "BOLILLO X6", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "18.00", + "box": { + "x": 0.7, + "y": 0.085, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "SUBTOTAL", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "54.00", + "box": { + "x": 0.7, + "width": 0.25, + "height": 0.022, + "y": 0.14800000000000002 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "54.00", + "box": { + "x": 0.7, + "width": 0.25, + "height": 0.022, + "y": 0.21200000000000002 + }, + "confidence": 1 + }, + { + "text": "EFECTIVO", + "box": { + "x": 0.05, + "y": 0.24400000000000002, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "100.00", + "box": { + "x": 0.7, + "width": 0.25, + "height": 0.022, + "y": 0.276 + }, + "confidence": 1 + }, + { + "text": "CAMBIO", + "box": { + "x": 0.05, + "y": 0.30800000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "46.00", + "box": { + "x": 0.7, + "width": 0.25, + "height": 0.022, + "y": 0.3400000000000001 + }, + "confidence": 1 + } + ], + "expected": { + "total": 54.0, + "merchant": "Panaderia La Espiga", + "category": "Food" + } +} diff --git a/SavelyTests/Fixtures/Receipts/no-total-label.json b/SavelyTests/Fixtures/Receipts/no-total-label.json new file mode 100644 index 0000000..9f23abf --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/no-total-label.json @@ -0,0 +1,92 @@ +{ + "id": "no-total-label", + "source": "hand-authored: a note with prices but no total label at all (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "NOTA", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "LIMPIEZA", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "300.00", + "box": { + "x": 0.7, + "y": 0.053000000000000005, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "MATERIAL", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "120.00", + "box": { + "x": 0.7, + "y": 0.085, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "420.00", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "GRACIAS", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 420.0, + "confidence": "low" + } +} diff --git a/SavelyTests/Fixtures/Receipts/ocr-noise-in-digits.json b/SavelyTests/Fixtures/Receipts/ocr-noise-in-digits.json new file mode 100644 index 0000000..58a9713 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/ocr-noise-in-digits.json @@ -0,0 +1,122 @@ +{ + "id": "ocr-noise-in-digits", + "source": "hand-authored: OCR confusions inside amounts (O for 0, S for 5) (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "MINISUPER", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "LECHE", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "2S.00", + "box": { + "x": 0.7, + "y": 0.053000000000000005, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "PAN", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "1O.00", + "box": { + "x": 0.7, + "y": 0.085, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "3S.OO", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "EFECTIVO", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "5O.00", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CAMBIO", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "15.00", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 35.0, + "confidence": "high" + } +} diff --git a/SavelyTests/Fixtures/Receipts/oxxo-cash.json b/SavelyTests/Fixtures/Receipts/oxxo-cash.json new file mode 100644 index 0000000..87d45f7 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/oxxo-cash.json @@ -0,0 +1,249 @@ +{ + "id": "oxxo-cash", + "source": "hand-authored OXXO-style thermal ticket (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "OXXO", + "box": { + "x": 0.38, + "width": 0.24, + "height": 0.04, + "y": 0.02 + }, + "confidence": 1 + }, + { + "text": "CADENA COMERCIAL OXXO SA DE CV", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "AV. REVOLUCION 1234 COL. CENTRO", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TIJUANA B.C. C.P. 22000", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "RFC CCO8605231N4", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TIENDA 50123 CAJA 2 FOLIO 88213", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "FECHA 14/08/2026 18:42", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "COCA COLA 600ML", + "box": { + "x": 0.05, + "y": 0.24400000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "18.00", + "box": { + "x": 0.7, + "y": 0.24500000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "SABRITAS 45G", + "box": { + "x": 0.05, + "y": 0.276, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "17.50", + "box": { + "x": 0.7, + "y": 0.277, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "SUBTOTAL", + "box": { + "x": 0.05, + "y": 0.30800000000000005, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "35.50", + "box": { + "x": 0.7, + "y": 0.30900000000000005, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "IVA", + "box": { + "x": 0.05, + "y": 0.3400000000000001, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "5.68", + "box": { + "x": 0.7, + "y": 0.3410000000000001, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.3720000000000001, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "41.18", + "box": { + "x": 0.7, + "y": 0.3730000000000001, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "EFECTIVO", + "box": { + "x": 0.05, + "y": 0.40400000000000014, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "100.00", + "box": { + "x": 0.7, + "y": 0.40500000000000014, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CAMBIO", + "box": { + "x": 0.05, + "y": 0.43600000000000017, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "58.82", + "box": { + "x": 0.7, + "y": 0.43700000000000017, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "ARTICULOS 2", + "box": { + "x": 0.05, + "y": 0.4680000000000002, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "GRACIAS POR SU COMPRA", + "box": { + "x": 0.05, + "y": 0.5000000000000002, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 41.18, + "alternativesInclude": [ + 35.5 + ], + "alternativesExclude": [], + "merchant": "OXXO", + "date": "2026-08-14", + "category": "Food", + "confidence": "high" + } +} diff --git a/SavelyTests/Fixtures/Receipts/pemex-importe.json b/SavelyTests/Fixtures/Receipts/pemex-importe.json new file mode 100644 index 0000000..636ecf5 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/pemex-importe.json @@ -0,0 +1,134 @@ +{ + "id": "pemex-importe", + "source": "hand-authored fuel ticket where the total is labelled IMPORTE (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "PEMEX", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "GASOLINERA LA MESA SA DE CV", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "DESPACHADOR 3", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "MAGNA", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "20.00 LTS", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "PRECIO", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "22.99", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "IMPORTE", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "459.80", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TARJETA", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "459.80", + "box": { + "x": 0.7, + "y": 0.21300000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "FECHA: 13-08-2026 HORA: 07:55", + "box": { + "x": 0.05, + "y": 0.24400000000000002, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 459.8, + "merchant": "Pemex", + "category": "Transit", + "date": "2026-08-13" + } +} diff --git a/SavelyTests/Fixtures/Receipts/percent-and-noise.json b/SavelyTests/Fixtures/Receipts/percent-and-noise.json new file mode 100644 index 0000000..2eb6c40 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/percent-and-noise.json @@ -0,0 +1,148 @@ +{ + "id": "percent-and-noise", + "source": "hand-authored: percentages, phone numbers and ticket ids must not become amounts (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "SUPER LEY", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TEL 664 123 45 67", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TICKET 0004512879", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "DESCUENTO 10%", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "-12.00", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "IVA 16.00%", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "15.36", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "111.36", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "EFECTIVO", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "120.00", + "box": { + "x": 0.7, + "y": 0.21300000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CAMBIO", + "box": { + "x": 0.05, + "y": 0.24400000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "8.64", + "box": { + "x": 0.7, + "y": 0.24500000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 111.36, + "alternativesExclude": [ + 16.0, + 10.0, + 4512879, + 664 + ], + "category": "Food" + } +} diff --git a/SavelyTests/Fixtures/Receipts/restaurant-tip.json b/SavelyTests/Fixtures/Receipts/restaurant-tip.json new file mode 100644 index 0000000..5da41a8 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/restaurant-tip.json @@ -0,0 +1,228 @@ +{ + "id": "restaurant-tip", + "source": "hand-authored restaurant ticket with tip lines (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "TAQUERIA EL PAISA", + "box": { + "x": 0.2, + "width": 0.6, + "height": 0.034, + "y": 0.02 + }, + "confidence": 1 + }, + { + "text": "BLVD AGUA CALIENTE 500", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "MESA 4 MESERO JUAN", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "15/08/2026 21:10", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "3 TACOS ADOBADA", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "90.00", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "1 AGUA HORCHATA", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "50.00", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "SUBTOTAL", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "140.00", + "box": { + "x": 0.7, + "y": 0.21300000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "IVA 16%", + "box": { + "x": 0.05, + "y": 0.24400000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "22.40", + "box": { + "x": 0.7, + "y": 0.24500000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.276, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "162.40", + "box": { + "x": 0.7, + "y": 0.277, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "PROPINA", + "box": { + "x": 0.05, + "y": 0.30800000000000005, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "24.36", + "box": { + "x": 0.7, + "y": 0.30900000000000005, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL CON PROPINA", + "box": { + "x": 0.05, + "y": 0.3400000000000001, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "186.76", + "box": { + "x": 0.7, + "y": 0.3410000000000001, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TARJETA VISA ****1234", + "box": { + "x": 0.05, + "y": 0.3720000000000001, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "186.76", + "box": { + "x": 0.7, + "y": 0.3730000000000001, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "GRACIAS POR SU VISITA", + "box": { + "x": 0.05, + "y": 0.40400000000000014, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 162.4, + "alternativesInclude": [ + 186.76 + ], + "merchant": "Taqueria El Paisa", + "date": "2026-08-15", + "category": "Food", + "confidence": "high" + } +} diff --git a/SavelyTests/Fixtures/Receipts/uber-eats-mixed-case.json b/SavelyTests/Fixtures/Receipts/uber-eats-mixed-case.json new file mode 100644 index 0000000..18a771d --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/uber-eats-mixed-case.json @@ -0,0 +1,145 @@ +{ + "id": "uber-eats-mixed-case", + "source": "hand-authored delivery receipt in mixed case with a $ total (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "Uber Eats", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "Recibo de tu pedido", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "16 ago 2026 · 20:15", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "Subtotal", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$159.00", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "Tarifa de servicio", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$15.00", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "Entrega", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$15.00", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "Total", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$189.00", + "box": { + "x": 0.7, + "y": 0.21300000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "Pagado con Visa 4242", + "box": { + "x": 0.05, + "y": 0.24400000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$189.00", + "box": { + "x": 0.7, + "y": 0.24500000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 189.0, + "merchant": "Uber Eats", + "category": "Food", + "date": "2026-08-16", + "confidence": "high" + } +} diff --git a/SavelyTests/Fixtures/Receipts/us-grocery-cash.json b/SavelyTests/Fixtures/Receipts/us-grocery-cash.json new file mode 100644 index 0000000..770953f --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/us-grocery-cash.json @@ -0,0 +1,238 @@ +{ + "id": "us-grocery-cash", + "source": "hand-authored US grocery receipt, month-first date, cash/change (not a real receipt)", + "locale": "en_US", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "TRADER JOE'S", + "box": { + "x": 0.25, + "width": 0.5, + "height": 0.034, + "y": 0.02 + }, + "confidence": 1 + }, + { + "text": "1090 3RD AVE SAN DIEGO CA", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "STORE #189 (619) 555-0100", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "BANANAS", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "1.99", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "OAT MILK", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "3.49", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CHICKEN BURRITO", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "12.01", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "SUBTOTAL", + "box": { + "x": 0.05, + "y": 0.21200000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "17.49", + "box": { + "x": 0.7, + "y": 0.21300000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TAX", + "box": { + "x": 0.05, + "y": 0.24400000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "1.53", + "box": { + "x": 0.7, + "y": 0.24500000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.276, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "19.02", + "box": { + "x": 0.7, + "y": 0.277, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CASH", + "box": { + "x": 0.05, + "y": 0.30800000000000005, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "20.00", + "box": { + "x": 0.7, + "y": 0.30900000000000005, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CHANGE", + "box": { + "x": 0.05, + "y": 0.3400000000000001, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "0.98", + "box": { + "x": 0.7, + "y": 0.3410000000000001, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "ITEMS 3", + "box": { + "x": 0.05, + "y": 0.3720000000000001, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "08/14/2026 5:32 PM", + "box": { + "x": 0.05, + "y": 0.40400000000000014, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "THANK YOU FOR SHOPPING", + "box": { + "x": 0.05, + "y": 0.43600000000000017, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 19.02, + "alternativesInclude": [ + 17.49 + ], + "merchant": "Trader Joe's", + "date": "2026-08-14", + "category": "Food", + "confidence": "high" + } +} diff --git a/SavelyTests/Fixtures/Receipts/whole-peso-total.json b/SavelyTests/Fixtures/Receipts/whole-peso-total.json new file mode 100644 index 0000000..44dee90 --- /dev/null +++ b/SavelyTests/Fixtures/Receipts/whole-peso-total.json @@ -0,0 +1,113 @@ +{ + "id": "whole-peso-total", + "source": "hand-authored: whole-peso total with a currency sign and no decimals (not a real receipt)", + "locale": "es_MX", + "now": "2026-08-16T12:00:00Z", + "lines": [ + { + "text": "ESTACIONAMIENTO PLAZA", + "box": { + "x": 0.05, + "y": 0.02, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TICKET 004512", + "box": { + "x": 0.05, + "y": 0.052000000000000005, + "width": 0.9, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "2 HORAS", + "box": { + "x": 0.05, + "y": 0.084, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$250", + "box": { + "x": 0.7, + "y": 0.085, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "TOTAL", + "box": { + "x": 0.05, + "y": 0.116, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$250", + "box": { + "x": 0.7, + "y": 0.117, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "PAGO", + "box": { + "x": 0.05, + "y": 0.14800000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$300", + "box": { + "x": 0.7, + "y": 0.14900000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "CAMBIO", + "box": { + "x": 0.05, + "y": 0.18000000000000002, + "width": 0.45, + "height": 0.022 + }, + "confidence": 1 + }, + { + "text": "$50", + "box": { + "x": 0.7, + "y": 0.18100000000000002, + "width": 0.25, + "height": 0.022 + }, + "confidence": 1 + } + ], + "expected": { + "total": 250, + "category": "Transit", + "confidence": "high" + } +} diff --git a/SavelyTests/ReceiptFixtureTests.swift b/SavelyTests/ReceiptFixtureTests.swift new file mode 100644 index 0000000..d1427a6 --- /dev/null +++ b/SavelyTests/ReceiptFixtureTests.swift @@ -0,0 +1,115 @@ +// +// ReceiptFixtureTests.swift +// SavelyTests +// +// Runs ReceiptParser over every JSON fixture in Fixtures/Receipts and +// checks the expected fields. Fixtures are OCR dumps (text + normalized +// boxes) — the same shape the DEBUG "Export OCR dump" action writes on a +// device — so a real receipt that misparses becomes a regression test by +// dropping its anonymized JSON in that folder. No images are committed. +// + +import XCTest +@testable import Savely + +final class ReceiptFixtureTests: XCTestCase { + + struct Fixture: Decodable { + struct Expected: Decodable { + var total: Double? + var alternativesInclude: [Double]? + var alternativesExclude: [Double]? + var merchant: String? + var date: String? + var category: String? + var confidence: String? + var currencyHint: String? + } + var id: String + var source: String + var locale: String + var now: String + var lines: [OCRLine] + var expected: Expected + } + + private static let iso = ISO8601DateFormatter() + + private func loadFixtures() throws -> [Fixture] { + let bundle = Bundle(for: ReceiptFixtureTests.self) + let fixtures = try XCTUnwrap(bundle.url(forResource: "Fixtures", withExtension: nil), + "Fixtures folder is not in the test bundle") + let folder = fixtures.appendingPathComponent("Receipts", isDirectory: true) + let urls = try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + XCTAssertFalse(urls.isEmpty, "no fixtures found") + return try urls.map { try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: $0)) } + } + + func testEveryFixtureParsesAsExpected() throws { + let fixtures = try loadFixtures() + var failures: [String] = [] + + for fixture in fixtures { + let now = try XCTUnwrap(Self.iso.date(from: fixture.now), "\(fixture.id): bad now") + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .current + let draft = ReceiptParser.parse( + lines: fixture.lines, now: now, locale: Locale(identifier: fixture.locale), calendar: calendar + ) + let e = fixture.expected + func fail(_ what: String) { failures.append("[\(fixture.id)] \(what) rows=\(draft.rows)") } + + if let total = e.total { + if let got = draft.total?.value { + if !ReceiptParser.approximatelyEqual(got, total) { + fail("total expected \(total) got \(got) (\(draft.total?.rowText ?? ""))") + } + } else { + fail("total expected \(total) got nil") + } + } + for alt in e.alternativesInclude ?? [] where !draft.alternatives.contains(where: { ReceiptParser.approximatelyEqual($0.value, alt) }) { + fail("alternatives should include \(alt); got \(draft.alternatives.map(\.value))") + } + for alt in e.alternativesExclude ?? [] { + if draft.alternatives.contains(where: { ReceiptParser.approximatelyEqual($0.value, alt) }) + || (draft.total.map { ReceiptParser.approximatelyEqual($0.value, alt) } ?? false) { + fail("\(alt) must not be offered; got total \(String(describing: draft.total?.value)) alts \(draft.alternatives.map(\.value))") + } + } + if let merchant = e.merchant, draft.merchant != merchant { + fail("merchant expected \(merchant) got \(String(describing: draft.merchant))") + } + if let dateString = e.date { + if let got = draft.date { + let comps = calendar.dateComponents([.year, .month, .day], from: got) + let gotString = String(format: "%04d-%02d-%02d", comps.year ?? 0, comps.month ?? 0, comps.day ?? 0) + if gotString != dateString { fail("date expected \(dateString) got \(gotString)") } + } else { + fail("date expected \(dateString) got nil") + } + } + if let category = e.category, draft.categoryHint?.rawValue != category { + fail("category expected \(category) got \(String(describing: draft.categoryHint))") + } + if let confidence = e.confidence, "\(draft.confidence)" != confidence { + fail("confidence expected \(confidence) got \(draft.confidence)") + } + if let currency = e.currencyHint, draft.currencyHint != currency { + fail("currencyHint expected \(currency) got \(String(describing: draft.currencyHint))") + } + } + + XCTAssertTrue(failures.isEmpty, "\n" + failures.joined(separator: "\n")) + } + + func testFixturesDeclareTheirProvenance() throws { + // Honesty rule: every fixture says where it came from, so nobody + // mistakes a hand-authored layout for measured real-world accuracy. + for fixture in try loadFixtures() { + XCTAssertFalse(fixture.source.isEmpty, "\(fixture.id) has no source") + } + } +} diff --git a/SavelyTests/ReceiptOCRIntegrationTests.swift b/SavelyTests/ReceiptOCRIntegrationTests.swift new file mode 100644 index 0000000..0a969f7 --- /dev/null +++ b/SavelyTests/ReceiptOCRIntegrationTests.swift @@ -0,0 +1,95 @@ +// +// ReceiptOCRIntegrationTests.swift +// SavelyTests +// +// Runs the REAL on-device pipeline — Vision (RecognizeDocumentsRequest / +// RecognizeTextRequest) → ReceiptParser — on a synthetic, script-rendered +// receipt image. This is the one test that proves the two halves fit +// together (orientation, languages, box conversion, row clustering); +// parser rules themselves are covered by the JSON fixtures. +// + +import XCTest +import UIKit +@testable import Savely + +final class ReceiptOCRIntegrationTests: XCTestCase { + + private func loadImage(_ name: String) throws -> UIImage { + let bundle = Bundle(for: ReceiptOCRIntegrationTests.self) + let fixtures = try XCTUnwrap(bundle.url(forResource: "Fixtures", withExtension: nil)) + let url = fixtures.appendingPathComponent("Images/\(name)") + let data = try Data(contentsOf: url) + return try XCTUnwrap(UIImage(data: data)) + } + + func testSyntheticOXXOReceiptReadsTotalMerchantAndDate() async throws { + let image = try loadImage("synthetic-oxxo.png") + let result: ReceiptOCRResult + do { + result = try await ReceiptOCR.recognize(image) + } catch ReceiptOCRError.noText { + throw XCTSkip("Vision recognized no text in this environment") + } + XCTAssertFalse(result.lines.isEmpty) + + // The image is rendered upright; every box must be inside the unit square, + // top-left origin (a merchant line near the top has a small y). + for line in result.lines { + XCTAssert((0...1).contains(line.box.x) && (0...1).contains(line.box.y), "box out of range: \(line)") + } + let oxxo = result.lines.first { $0.text.uppercased().contains("OXXO") } + XCTAssertNotNil(oxxo) + if let oxxo { XCTAssertLessThan(oxxo.box.y, 0.3, "the header should be near the top after the y-flip") } + + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC") ?? .current + let now = utc.date(from: DateComponents(year: 2026, month: 8, day: 16, hour: 12)) ?? Date() + let draft = ReceiptParser.parse(lines: result.lines, now: now, locale: Locale(identifier: "es_MX"), calendar: utc) + + XCTAssertEqual(draft.total?.value ?? 0, 57.50, accuracy: 0.001, "rows: \(draft.rows)") + XCTAssertEqual(draft.merchant, "OXXO") + XCTAssertEqual(draft.categoryHint, .food) + if let date = draft.date { + XCTAssertEqual(utc.component(.day, from: date), 14) + XCTAssertEqual(utc.component(.month, from: date), 8) + } else { + XCTFail("date not read; rows: \(draft.rows)") + } + XCTAssertTrue(draft.alternatives.contains { ReceiptParser.approximatelyEqual($0.value, 49.57) }, + "subtotal should be offered; got \(draft.alternatives.map(\.value))") + } + + func testOrientationIsPassedToVision() async throws { + // A sideways bitmap with the matching orientation tag (a portrait + // photo as the sensor stores it) must still read — exactly the case + // the old TextRecognizer broke by dropping the orientation. + let upright = try loadImage("synthetic-oxxo.png") + let cg = try XCTUnwrap(upright.cgImage) + // Rotate the pixels by 90°, then tag the image so that Vision has to + // rotate them back. One of the two sideways tags is the correct + // inverse; that one must read the total. + let rotatedPixels = try XCTUnwrap(rotate90(cg)) + var totals: [Double] = [] + for orientation in [UIImage.Orientation.left, .right] { + let tagged = UIImage(cgImage: rotatedPixels, scale: 1, orientation: orientation) + guard let result = try? await ReceiptOCR.recognize(tagged) else { continue } + let draft = ReceiptParser.parse(lines: result.lines, locale: Locale(identifier: "es_MX")) + totals.append(draft.total?.value ?? 0) + } + XCTAssertTrue(totals.contains { abs($0 - 57.50) < 0.001 }, "neither tag read the total: \(totals)") + } + + private func rotate90(_ image: CGImage) -> CGImage? { + let width = image.height, height = image.width + guard let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + context.translateBy(x: CGFloat(width) / 2, y: CGFloat(height) / 2) + context.rotate(by: -.pi / 2) + context.translateBy(x: -CGFloat(image.width) / 2, y: -CGFloat(image.height) / 2) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return context.makeImage() + } +} diff --git a/SavelyTests/ReceiptParserTests.swift b/SavelyTests/ReceiptParserTests.swift new file mode 100644 index 0000000..3ce4db5 --- /dev/null +++ b/SavelyTests/ReceiptParserTests.swift @@ -0,0 +1,193 @@ +// +// ReceiptParserTests.swift +// SavelyTests +// +// Unit tests for the receipt grammar, date parser and the parser's row and +// scoring rules. End-to-end layouts live in ReceiptFixtureTests. +// + +import XCTest +@testable import Savely + +final class ReceiptParserTests: XCTestCase { + + // MARK: - Amount grammar + + func testAmountsWithAndWithoutThousandsSeparators() { + XCTAssertEqual(values("1,234.56"), [1234.56]) + XCTAssertEqual(values("1234.56"), [1234.56]) + XCTAssertEqual(values("12345.67"), [12345.67]) + XCTAssertEqual(values("1.234,56"), [1234.56]) + XCTAssertEqual(values("$1,234.56"), [1234.56]) + XCTAssertEqual(values("MXN 250.00"), [250]) + XCTAssertEqual(values("TOTAL:$41.18"), [41.18]) + XCTAssertEqual(values("TOTAL 8.50"), [8.5]) + XCTAssertEqual(values("250"), [250]) + XCTAssertEqual(values("12.5"), [12.5]) + XCTAssertEqual(values("8,50"), [8.5]) + } + + func testAmountGrammarSkipsPercentsDatesTimesAndLongIds() { + XCTAssertEqual(values("IVA 16.00%"), []) + XCTAssertEqual(values("IVA 16 %"), []) + XCTAssertEqual(values("16/08/2026 12:34"), []) + XCTAssertEqual(values("TICKET 0004512879"), []) + XCTAssertEqual(values("RFC CCO8605231N4"), []) + XCTAssertFalse(values("TEL (664) 123-4567").contains(4567)) + } + + func testAmountGrammarReadsCurrencyMarkers() { + let mxn = ReceiptAmountGrammar.amounts(in: "TOTAL 350.00 MXN") + XCTAssertEqual(mxn.first?.currency, "MXN") + let usd = ReceiptAmountGrammar.amounts(in: "TOTAL USD 19.50") + XCTAssertEqual(usd.first?.currency, "USD") + let dollar = ReceiptAmountGrammar.amounts(in: "TOTAL $41.18") + XCTAssertNil(dollar.first?.currency, "$ is ambiguous between MXN and USD") + } + + func testAmountGrammarNormalizesOCRConfusionsOnlyInsideNumbers() { + XCTAssertEqual(ReceiptAmountGrammar.parseNumber("1O0.00")?.value, 100) + XCTAssertEqual(ReceiptAmountGrammar.parseNumber("3S.OO")?.value, 35) + XCTAssertNil(ReceiptAmountGrammar.parseNumber("SOL")) + XCTAssertNil(ReceiptAmountGrammar.parseNumber("IVA")) + } + + func testAmountGrammarFlagsNegativesAndAmbiguity() throws { + let discount = try XCTUnwrap(ReceiptAmountGrammar.amounts(in: "DESCUENTO -12.00").first) + XCTAssertTrue(discount.isNegative) + let thousands = try XCTUnwrap(ReceiptAmountGrammar.parseNumber("1,234")) + XCTAssertEqual(thousands.value, 1234) + XCTAssertTrue(thousands.isAmbiguous) + } + + func testSpacedMinusAndGluedDollarMisread() throws { + let spaced = try XCTUnwrap(ReceiptAmountGrammar.amounts(in: "DESCUENTO - 12.00").first) + XCTAssertTrue(spaced.isNegative) + let trailing = try XCTUnwrap(ReceiptAmountGrammar.amounts(in: "AJUSTE 12.00 -").first) + XCTAssertTrue(trailing.isNegative) + // Vision reads a glued "$" as "S": that is the peso sign, not a 5. + XCTAssertEqual(values("TOTAL S41.18"), [41.18]) + let dollar = try XCTUnwrap(ReceiptAmountGrammar.amounts(in: "TOTAL $250").first) + XCTAssertTrue(dollar.hasCurrencyMarker) + XCTAssertNil(dollar.currency) + } + + // MARK: - Labels + + func testLabelClassification() { + XCTAssertEqual(label("TOTAL"), .total) + XCTAssertEqual(label("Total:"), .total) + XCTAssertEqual(label("TOTAL A PAGAR"), .totalStrong) + XCTAssertEqual(label("IMPORTE"), .total) + XCTAssertEqual(label("SUBTOTAL"), .excluded(.subtotal)) + XCTAssertEqual(label("SUB-TOTAL"), .excluded(.subtotal)) + XCTAssertEqual(label("TOTAL CON PROPINA"), .totalWithTip) + XCTAssertEqual(label("PROPINA"), .excluded(.tip)) + XCTAssertEqual(label("EFECTIVO"), .excluded(.cash)) + XCTAssertEqual(label("CAMBIO"), .excluded(.change)) + XCTAssertEqual(label("CASH TENDERED"), .excluded(.cash)) + XCTAssertEqual(label("CHANGE DUE"), .excluded(.change)) + XCTAssertEqual(label("TOTAL ARTICULOS 5"), .excluded(.other)) + XCTAssertEqual(label("IVA 16%"), .excluded(.tax)) + XCTAssertEqual(label("COCA COLA 600ML"), ReceiptLabelClass.none) + // TOTAL next to a payment/tax word is still the total. + XCTAssertEqual(label("TOTAL PAGADO"), .total) + XCTAssertEqual(label("Total pagado con tarjeta"), .total) + XCTAssertEqual(label("TOTAL IVA INCLUIDO"), .total) + XCTAssertEqual(label("TOTAL TARJETA"), .total) + XCTAssertEqual(label("TOTAL PROPINA"), .excluded(.tip)) + XCTAssertEqual(label("TOTAL IVA"), .excluded(.tax)) + } + + func testCardRowDigitsAndColumnHeadersAreNotAmounts() { + let draft = ReceiptParser.parse(lines: OCRLine.stacked([ + "CANT DESCRIPCION IMPORTE", + "2 MARGARITAS 26.00", + "1 CEVICHE 180.00", + "SUBTOTAL 232.00", + "TOTAL 232.00", + "TARJETA VISA ****1234 AUT 456789 232.00", + ]), locale: Locale(identifier: "es_MX")) + XCTAssertEqual(draft.total?.value ?? 0, 232.00, accuracy: 0.001) + let offered = draft.alternatives.map(\.value) + XCTAssertFalse(offered.contains(1234), "masked card digits are not money: \(offered)") + XCTAssertFalse(offered.contains(456789), "auth codes are not money: \(offered)") + XCTAssertFalse(offered.contains(2), "a quantity is not money: \(offered)") + XCTAssertNil(draft.date, "'2 MARGARITAS 26.00' is not a date in March") + } + + // MARK: - Dates + + func testDayFirstAndMonthFirstDates() { + let now = date(2026, 8, 16) + XCTAssertEqual(firstDate("FECHA 14/08/2026 18:42", order: .dayFirst, now: now), date(2026, 8, 14)) + XCTAssertEqual(firstDate("08/14/2026 5:32 PM", order: .monthFirst, now: now), date(2026, 8, 14)) + // Unambiguous regardless of the order hint. + XCTAssertEqual(firstDate("14/08/2026", order: .monthFirst, now: now), date(2026, 8, 14)) + XCTAssertEqual(firstDate("16-08-26", order: .dayFirst, now: now), date(2026, 8, 16)) + XCTAssertEqual(firstDate("2026-08-13", order: .dayFirst, now: now), date(2026, 8, 13)) + XCTAssertEqual(firstDate("16 ago 2026", order: .dayFirst, now: now), date(2026, 8, 16)) + XCTAssertEqual(firstDate("16/AGO/26", order: .dayFirst, now: now), date(2026, 8, 16)) + XCTAssertEqual(firstDate("Aug 14, 2026", order: .monthFirst, now: now), date(2026, 8, 14)) + } + + func testImplausibleDatesAreRejected() { + let now = date(2026, 8, 16) + XCTAssertNil(firstDate("31/12/2026", order: .dayFirst, now: now), "future") + XCTAssertNil(firstDate("01/01/2020", order: .dayFirst, now: now), "older than two years") + XCTAssertNil(firstDate("30/02/2026", order: .dayFirst, now: now), "no such day") + XCTAssertNil(firstDate("TOTAL 41.18", order: .dayFirst, now: now)) + } + + // MARK: - Rows + + func testRowsAreClusteredByVerticalOverlapAndOrderedLeftToRight() { + let lines = [ + OCRLine(text: "41.18", box: OCRBox(x: 0.7, y: 0.101, width: 0.25, height: 0.02)), + OCRLine(text: "TOTAL", box: OCRBox(x: 0.05, y: 0.10, width: 0.3, height: 0.02)), + OCRLine(text: "SUBTOTAL", box: OCRBox(x: 0.05, y: 0.06, width: 0.3, height: 0.02)), + OCRLine(text: "35.50", box: OCRBox(x: 0.7, y: 0.06, width: 0.25, height: 0.02)), + ] + let rows = ReceiptParser.buildRows(from: lines) + XCTAssertEqual(rows.map(\.text), ["SUBTOTAL 35.50", "TOTAL 41.18"]) + } + + // MARK: - Merchant + + func testMerchantPrettifying() { + XCTAssertEqual(ReceiptParser.prettifiedMerchant("TIENDAS DEL SOL S.A. DE C.V."), "Tiendas Del Sol") + XCTAssertEqual(ReceiptParser.prettifiedMerchant("Café La Flor"), "Café La Flor") + XCTAssertEqual(ReceiptParser.prettifiedMerchant("ABARROTES DON PEPE"), "Abarrotes Don Pepe") + } + + func testEmptyInputGivesEmptyDraft() { + XCTAssertEqual(ReceiptParser.parse(lines: []), .empty) + let noAmounts = ReceiptParser.parse(lines: OCRLine.stacked(["GRACIAS POR SU COMPRA"])) + XCTAssertNil(noAmounts.total) + XCTAssertEqual(noAmounts.confidence, .none) + } + + // MARK: - Helpers + + private func values(_ text: String) -> [Double] { + ReceiptAmountGrammar.amounts(in: text).map(\.value) + } + + private func label(_ text: String) -> ReceiptLabelClass { + ReceiptVocabulary.labelClass(of: ReceiptText.normalized(text)) + } + + private var utc: Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC") ?? .current + return c + } + + private func date(_ y: Int, _ m: Int, _ d: Int) -> Date { + utc.date(from: DateComponents(year: y, month: m, day: d, hour: 12)) ?? Date() + } + + private func firstDate(_ text: String, order: ReceiptDateParser.DayMonthOrder, now: Date) -> Date? { + ReceiptDateParser.dates(in: text, order: order, now: now, calendar: utc).first?.date + } +} diff --git a/SavelyUITests/ReceiptScanUITests.swift b/SavelyUITests/ReceiptScanUITests.swift new file mode 100644 index 0000000..06c895e --- /dev/null +++ b/SavelyUITests/ReceiptScanUITests.swift @@ -0,0 +1,65 @@ +// +// ReceiptScanUITests.swift +// SavelyUITests +// +// Walks the receipt flow the way the Simulator can: "+" → Scan a receipt → +// Choose from Photos → (the newest photo) → review → Save. Needs a receipt +// image in the Simulator's photo library — see the "Test plan" in the PR: +// xcrun simctl addmedia SavelyTests/Fixtures/Images/synthetic-oxxo.png +// Skips itself when the picker shows no photos, so it never fails for lack +// of setup. Not run on CI (SavelyUITests is skipped there). +// + +import XCTest + +final class ReceiptScanUITests: XCTestCase { + + @MainActor + func testScanFromPhotosReviewsAndSavesTheExpense() throws { + let app = XCUIApplication() + app.launchArguments += ["-isOnboardingComplete", "YES"] + app.launch() + + // The splash screen holds the tab bar back for a moment. + let addButton = app.buttons["Add"] + XCTAssertTrue(addButton.waitForExistence(timeout: 20), "the main tab bar did not appear") + addButton.tap() + let scan = app.buttons["Scan a receipt, We'll read the total, merchant and date"] + if scan.waitForExistence(timeout: 3) { + scan.tap() + } else { + app.staticTexts["Scan a receipt"].tap() + } + + let picker = app.buttons["Choose from Photos"] + XCTAssertTrue(picker.waitForExistence(timeout: 5), "the scanner did not open") + picker.tap() + + // The Photos picker is a remote view; its grid is reachable through + // the app once it has loaded (slow on a busy Simulator). + let photo = app.images.matching(NSPredicate(format: "label BEGINSWITH 'Photo'")).firstMatch + guard photo.waitForExistence(timeout: 60) else { + throw XCTSkip("no photo in the Simulator library (or the picker never loaded) — add SavelyTests/Fixtures/Images/synthetic-oxxo.png with simctl addmedia") + } + photo.tap() + + let title = app.staticTexts["Check the receipt"] + XCTAssertTrue(title.waitForExistence(timeout: 30), "the review screen did not appear") + + let amount = app.otherElements["Expense amount"] + XCTAssertTrue(amount.waitForExistence(timeout: 2)) + let amountValue = amount.value as? String ?? "" + XCTAssertEqual(amountValue, "57.50", "the total read from synthetic-oxxo.png") + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "receipt-review" + attachment.lifetime = .keepAlways + add(attachment) + + app.buttons["Save expense"].tap() + XCTAssertTrue(app.buttons["Add"].waitForExistence(timeout: 5), "the scanner should dismiss after saving") + + app.buttons["Money"].firstMatch.tap() + XCTAssertTrue(app.staticTexts["OXXO"].waitForExistence(timeout: 5), "the saved row should show the merchant") + } +} From 064575a4462e8cf53967328bf6659931e596717d Mon Sep 17 00:00:00 2001 From: Belli Date: Mon, 17 Aug 2026 20:08:34 -0700 Subject: [PATCH 3/3] chore(strings): let Xcode re-sort the catalog, drop three stale literals --- Savely/Resources/Localizable.xcstrings | 2295 ++++++++++++------------ 1 file changed, 1143 insertions(+), 1152 deletions(-) diff --git a/Savely/Resources/Localizable.xcstrings b/Savely/Resources/Localizable.xcstrings index 8b12251..4159ab8 100755 --- a/Savely/Resources/Localizable.xcstrings +++ b/Savely/Resources/Localizable.xcstrings @@ -10,3635 +10,3613 @@ " of %lld" : { }, - "error_invalid_amount" : { - "comment" : "Shown when an amount is empty, not a number, or not positive", - "extractionState" : "extracted_with_value", + "—" : { + + }, + ", favorite" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Enter a valid amount greater than zero." + "value" : ", favorite" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Escribe un monto válido mayor que cero." + "value" : ", favorita" } } } }, - "error_save_expense_failed" : { - "comment" : "Shown when writing an expense to the local store fails", - "extractionState" : "extracted_with_value", + ".00" : { + + }, + "/wk" : { + + }, + "%" : { + + }, + "%@ · %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "We couldn't save the expense. Please try again." - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "No pudimos guardar el gasto. Inténtalo de nuevo." + "value" : "%1$@ · %2$@" } } } }, - "scan_alternatives_label" : { - "comment" : "Label above the alternative amount chips", - "extractionState" : "extracted_with_value", + "%@ %lld percent" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Also on the receipt" + "value" : "%@ %lld percent" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "También en el recibo" + "value" : "%1$@ %2$lld por ciento" } } } }, - "scan_amount_accessibility_label" : { - "comment" : "Accessibility label of the amount on the review screen", - "extractionState" : "extracted_with_value", + "%@ dollars" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Expense amount" + "value" : "%@ dollars" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Monto del gasto" + "value" : "%@ dólares" } } } }, - "scan_camera_starting_body" : { - "comment" : "Shown when the shutter was tapped before the camera was ready", - "extractionState" : "extracted_with_value", + "%@ is on your home screen.\nAdd the first dollar?" : { + + }, + "%@, %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "The camera is still starting — try again in a moment." + "value" : "%@, %@" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "La cámara aún está iniciando — inténtalo de nuevo en un momento." + "value" : "%1$@, %2$@" } } } }, - "scan_camera_unavailable_body" : { - "comment" : "Body when the camera cannot start", - "extractionState" : "extracted_with_value", + "%@, %@, %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Allow camera access in Settings, or choose a receipt photo instead." + "value" : "%@, %@, %@" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Permite el acceso a la cámara en Ajustes, o elige una foto del recibo." + "value" : "%1$@, %2$@, %3$@" } } } }, - "scan_camera_unavailable_title" : { - "comment" : "Shown when the camera cannot start", - "extractionState" : "extracted_with_value", + "%@, %@%@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Camera not available" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cámara no disponible" + "value" : "%1$@, %2$@%3$@" } } } }, - "scan_close_label" : { - "comment" : "Accessibility label of the close button", - "extractionState" : "extracted_with_value", + "%@%@, %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Close" + "value" : "%@%@, %@" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Cerrar" + "value" : "%1$@%2$@, %3$@" } } } }, - "scan_date_from_receipt_note" : { - "comment" : "Note next to the date when it was read from the receipt", - "extractionState" : "extracted_with_value", + "%lld" : { + + }, + "%lld active · %@ saved of %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "from the receipt" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "del recibo" + "value" : "%1$lld active · %2$@ saved of %3$@" } } } }, - "scan_date_label" : { - "comment" : "Label of the date picker on the review screen", - "extractionState" : "extracted_with_value", + "%lld earned,\n%lld to go." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Date" + "value" : "%1$lld earned,\n%2$lld to go." } - }, + } + } + }, + "%lld percent" : { + "localizations" : { "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Fecha" + "value" : "%lld por ciento" } } } }, - "scan_date_today_note" : { - "comment" : "Note next to the date when it defaulted to today", - "extractionState" : "extracted_with_value", + "%lld percent, %@ of %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "today — change it if the receipt is older" + "value" : "%lld percent, %@ of %@" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "hoy — cámbiala si el recibo es de otro día" + "value" : "%1$lld por ciento, %2$@ de %3$@" } } } }, - "scan_enter_amount_error" : { - "comment" : "Shown when saving with no amount", - "extractionState" : "extracted_with_value", + "%lld%%" : { + + }, + "+%@" : { + + }, + "+$" : { + + }, + "~$%lld/mo" : { + + }, + "−%@" : { + + }, + "−$" : { + + }, + "$" : { + + }, + "$%.2f / $%.2f" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Enter a valid amount greater than zero." + "value" : "$%1$.2f / $%2$.2f" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Escribe un monto válido mayor que cero." + "value" : "$%1$.2f / $%2$.2f" } } } }, - "scan_export_dump_label" : { - "comment" : "DEBUG-only button that shares what OCR read as JSON", - "extractionState" : "extracted_with_value", + "$%@" : { + + }, + "$%lld" : { + + }, + "$%lld/week" : { + + }, + "$0" : { + + }, + "0" : { + + }, + "0.00" : { + + }, + "7-month trend" : { + + }, + "A goal is a name, a number, and a date.\nSavely takes care of the rest." : { + + }, + "About " : { + + }, + "Achievements" : { + + }, + "achievements_title" : { + "comment" : "Achievements Title", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Export OCR dump" + "value" : "Achievements and Medals" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Exportar OCR (JSON)" + "value" : "Logros y Medallas" } } } }, - "scan_guide_hint" : { - "comment" : "Hint under the camera guide frame", - "extractionState" : "extracted_with_value", + "Add" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Frame the whole receipt — flat, in good light." + "value" : "Add" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Encuadra todo el recibo — plano y con buena luz." + "value" : "Agregar" } } } }, - "scan_import_failed_body" : { - "comment" : "Shown when a photo picked from the library could not be loaded", - "extractionState" : "extracted_with_value", + "Add %@ to %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "We couldn't open that photo. Try another one, or take a new picture." - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "No pudimos abrir esa foto. Prueba con otra o toma una nueva." + "value" : "Add %1$@ to %2$@" } } } }, - "scan_import_photos_label" : { - "comment" : "Button that opens the photo picker to import a receipt", - "extractionState" : "extracted_with_value", + "Add $%@ to %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Choose from Photos" + "value" : "Add $%1$@ to %2$@" } - }, - "es-419" : { + } + } + }, + "Add $%lld to %@" : { + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Elegir de Fotos" + "state" : "new", + "value" : "Add $%1$lld to %2$@" } } } }, - "scan_merchant_placeholder" : { - "comment" : "Placeholder of the merchant field on the review screen", - "extractionState" : "extracted_with_value", + "Add a deposit" : { + + }, + "Add deposit" : { + + }, + "Add expense" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Merchant" + "value" : "Add expense" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Comercio" + "value" : "Agregar gasto" } } } }, - "scan_open_settings_label" : { - "comment" : "Button that opens the app's Settings page", - "extractionState" : "extracted_with_value", + "Add income" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Open Settings" + "value" : "Add income" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Abrir Ajustes" + "value" : "Agregar ingreso" } } } }, - "scan_privacy_note" : { - "comment" : "Reassurance shown in the scanner", + "Add your first expense above" : { + + }, + "add_expense_button" : { + "comment" : "Add Expense Button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Read on your iPhone. The photo never leaves your phone." + "value" : "Add Expense" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Se lee en tu iPhone. La foto nunca sale de tu teléfono." + "value" : "Agregar Gasto" } } } }, - "scan_processing_label" : { - "comment" : "Shown while OCR runs", + "add_income_button" : { + "comment" : "Add Income Button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Reading the receipt…" + "value" : "Add Income" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Leyendo el recibo…" + "value" : "Agregar Ingreso" } } } }, - "scan_read_failed_body" : { - "comment" : "Body when capture or reading fails", + "add_income_label" : { + "comment" : "Add Income Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Try again with the receipt flat and well lit, or enter the amount yourself." + "value" : "Add Income" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Inténtalo de nuevo con el recibo plano y bien iluminado, o escribe el monto tú mismo." + "value" : "Agregar Ingreso" } } } }, - "scan_read_failed_title" : { - "comment" : "Title when capture or reading fails", + "all_achievements_unlocked_label" : { + "comment" : "Shown when nothing is left to unlock", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "We couldn't read this one" + "value" : "Every achievement unlocked" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "No pudimos leer este recibo" + "value" : "Todos los logros desbloqueados" } } } }, - "scan_receipt_thumbnail_label" : { - "comment" : "Accessibility label of the receipt thumbnail", - "extractionState" : "extracted_with_value", + "ALMOST THERE" : { + + }, + "already_have_and_account_label" : { + "comment" : "Already have an account Label", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Photo of the receipt" + "value" : "Don't have an account?" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Foto del recibo" + "value" : "¿No tiene una cuenta?" } } } }, - "scan_retake_label" : { - "comment" : "Button that goes back to the camera", + "Amount" : { + + }, + "amount_placeholder_label" : { + "comment" : "Amount Placeholder Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Retake" + "value" : "Amount" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Repetir" + "value" : "Cantidad" } } } }, - "scan_retry_label" : { - "comment" : "Retry button after a failed read", + "app_preferences_title" : { + "comment" : "App Preferences Title", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Try again" + "value" : "App Preferences" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Intentar de nuevo" + "value" : "Preferencias de la Aplicación" } } } }, - "scan_review_lead_none" : { - "comment" : "Lead copy on the review screen when no amount was found", - "extractionState" : "extracted_with_value", + "Are you sure you want to delete this expense?" : { + + }, + "Are you sure you want to delete this goal?" : { + + }, + "Are you sure you want to delete this income?" : { + + }, + "AUTO" : { + + }, + "Auto-move on payday" : { + + }, + "Back" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "We couldn't find a total. Enter it below." + "value" : "Back" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "No encontramos un total. Escríbelo abajo." + "value" : "Atrás" } } } }, - "scan_review_lead_read" : { - "comment" : "Lead copy on the review screen when a total was found", + "Back to home" : { + + }, + "Badges unlock as you save — no pressure." : { + + }, + "blue_color" : { + "comment" : "Blue Color", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "We read this — is it right?" + "value" : "Blue" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Leímos esto — ¿está bien?" + "value" : "Azul" } } } }, - "scan_review_lead_unsure" : { - "comment" : "Lead copy on the review screen when the total is a low-confidence guess", + "Can't save yet" : { + + }, + "Cancel" : { + + }, + "cancel_button" : { + "comment" : "Cancel Button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "We're not sure about the total — please check it." + "value" : "Cancel" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "No estamos seguros del total — revísalo, por favor." + "value" : "Cancelar" } } } }, - "scan_review_title" : { - "comment" : "Title of the review screen after a scan", + "Category" : { + + }, + "category_placeholder" : { + "comment" : "Category Placeholder", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Check the receipt" + "value" : "Category" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Revisa el recibo" + "value" : "Categoria" } } } }, - "scan_save_expense_label" : { - "comment" : "Primary button on the review screen", - "extractionState" : "extracted_with_value", + "change_password_button" : { + "comment" : "Change Password Button", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Save expense" + "value" : "Change Password" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Guardar gasto" + "value" : "Cambiar Contraseña" } } } }, - "scan_scanned_receipt_description" : { - "comment" : "Description saved when the receipt had no readable merchant", - "extractionState" : "extracted_with_value", + "Clear name" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Scanned receipt" + "value" : "Clear name" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Recibo escaneado" + "value" : "Borrar nombre" } } } }, - "scan_shutter_label" : { - "comment" : "Accessibility label of the shutter button", - "extractionState" : "extracted_with_value", + "Close" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Take photo" + "value" : "Close" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Tomar foto" + "value" : "Cerrar" } } } }, - "scan_torch_off_label" : { - "comment" : "Accessibility label of the torch button when on", + "COLOR" : { + + }, + "color_label" : { + "comment" : "Color Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Turn flash off" + "value" : "Color" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Apagar flash" + "value" : "Color" } } } }, - "scan_torch_on_label" : { - "comment" : "Accessibility label of the torch button when off", - "extractionState" : "extracted_with_value", + "Completed" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Turn flash on" - } - }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Encender flash" + "value" : "Completadas" } } } }, - "We'll read the total, merchant and date" : { + "confirm_password_string" : { + "comment" : "Confirm Password String", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "We'll read the total, merchant and date" + "value" : "Confirm Password" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Leemos el total, el comercio y la fecha" + "value" : "Confirmar Contraseña" } } } }, - "—" : { - - }, - ", favorite" : { + "consistent_savings_description" : { + "comment" : "Description for consistent monthly savings", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : ", favorite" + "value" : "You have maintained consistent monthly savings for three months." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : ", favorita" - } - } - } - }, - ".00" : { - - }, - "/wk" : { - - }, - "%" : { - - }, - "%@ · %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ · %2$@" + "value" : "Ha mantenido ahorros mensuales constantes durante tres meses." } } } }, - "%@ %lld percent" : { + "consistent_savings_title" : { + "comment" : "Title for consistent monthly savings", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%@ %lld percent" + "value" : "Consistent Monthly Savings" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ %2$lld por ciento" + "value" : "Ahorros Mensuales Constantes" } } } }, - "%@ dollars" : { + "Continue" : { + + }, + "continue_button" : { + "comment" : "Continue Button", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%@ dollars" + "value" : "Continue" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "%@ dólares" + "value" : "Continuar" } } } }, - "%@ is on your home screen.\nAdd the first dollar?" : { + "Couldn't save the deposit" : { }, - "%@, %@" : { + "Couldn't save the goal" : { + + }, + "Create your first goal" : { + + }, + "create_account_button" : { + "comment" : "Create Account Button", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%@, %@" + "value" : "Create Account" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@, %2$@" + "value" : "Crear una cuenta" } } } }, - "%@, %@, %@" : { + "create_account_title" : { + "comment" : "Create Account Title", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%@, %@, %@" + "value" : "Create your account!" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@, %2$@, %3$@" - } - } - } - }, - "%@, %@%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@, %2$@%3$@" + "value" : "¡Crea tú cuenta!" } } } }, - "%@%@, %@" : { + "creating_account_label" : { + "comment" : "Creating Account Label", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%@%@, %@" + "value" : "By creating an account, I accept Savely's" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@%2$@, %3$@" + "value" : "Al crear una cuenta, acepto los" } } } }, - "%lld" : { - - }, - "%lld active · %@ saved of %@" : { + "dark_mode_label" : { + "comment" : "Dark Mode Label", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$lld active · %2$@ saved of %3$@" + "value" : "Dark Mode" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo Oscuro" } } } }, - "%lld earned,\n%lld to go." : { + "dashboard_tab_string" : { + "comment" : "Dashboard Tab String", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$lld earned,\n%2$lld to go." + "value" : "Dashboard" } - } - } - }, - "%lld percent" : { - "localizations" : { + }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "%lld por ciento" + "value" : "Panel" } } } }, - "%lld percent, %@ of %@" : { + "data_privacy_title" : { + "comment" : "Section header", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%lld percent, %@ of %@" + "value" : "Data & privacy" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld por ciento, %2$@ de %3$@" + "value" : "Datos y privacidad" } } } }, - "%lld%%" : { - - }, - "+%@" : { - - }, - "+$" : { - - }, - "~$%lld/mo" : { - - }, - "−%@" : { - - }, - "−$" : { - - }, - "$" : { - - }, - "$%.2f / $%.2f" : { - "extractionState" : "stale", + "data_stays_local_label" : { + "comment" : "One-line intro of the Data & privacy section", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "$%1$.2f / $%2$.2f" + "value" : "Your data never leaves this iPhone." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "$%1$.2f / $%2$.2f" + "value" : "Tus datos nunca salen de este iPhone." } } } }, - "$%@" : { - - }, - "$%lld" : { - - }, - "$%lld/week" : { - - }, - "$0" : { - - }, - "0" : { - - }, - "0.00" : { - - }, - "7-month trend" : { - - }, - "A goal is a name, a number, and a date.\nSavely takes care of the rest." : { - - }, - "About " : { - - }, - "Achievements" : { + "Day one" : { }, - "achievements_title" : { - "comment" : "Achievements Title", + "debt_paid_off_description" : { + "comment" : "Description for paying off debt", "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Achievements and Medals" + "value" : "You have paid off a major debt." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Logros y Medallas" + "value" : "Ha pagado una deuda importante." } } } }, - "Add" : { + "debt_paid_off_title" : { + "comment" : "Title for paying off debt", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Add" + "value" : "Debt Paid Off" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Agregar" - } - } - } - }, - "Add %@ to %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Add %1$@ to %2$@" - } - } - } - }, - "Add $%@ to %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Add $%1$@ to %2$@" - } - } - } - }, - "Add $%lld to %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Add $%1$lld to %2$@" + "value" : "Deuda Saldada" } } } }, - "Add a deposit" : { - - }, - "Add deposit" : { - - }, - "Add expense" : { + "Decimal point" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Add expense" + "value" : "Decimal point" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Agregar gasto" + "value" : "Punto decimal" } } } }, - "Add income" : { + "Delete" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Add income" - } - }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Agregar ingreso" + "value" : "Eliminar" } } } }, - "Add your first expense above" : { + "Delete Expense" : { }, - "add_expense_button" : { - "comment" : "Add Expense Button", + "Delete Goal" : { + + }, + "Delete Income" : { + + }, + "delete_all_data_confirm_button" : { + "comment" : "Destructive confirm button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Add Expense" + "value" : "Delete everything" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Agregar Gasto" + "value" : "Borrar todo" } } } }, - "add_income_button" : { - "comment" : "Add Income Button", + "delete_all_data_confirm_message" : { + "comment" : "Confirmation dialog message", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Add Income" + "value" : "Every income, expense and goal will be removed from this iPhone. Your name and reminder settings stay. This cannot be undone." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Agregar Ingreso" + "value" : "Se eliminarán todos los ingresos, gastos y metas de este iPhone. Tu nombre y tus recordatorios se conservan. Esto no se puede deshacer." } } } }, - "add_income_label" : { - "comment" : "Add Income Label", + "delete_all_data_confirm_title" : { + "comment" : "Confirmation dialog title", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Add Income" + "value" : "Delete all data?" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Agregar Ingreso" + "value" : "¿Borrar todos los datos?" } } } }, - "all_achievements_unlocked_label" : { - "comment" : "Shown when nothing is left to unlock", + "delete_all_data_label" : { + "comment" : "Destructive row", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Every achievement unlocked" + "value" : "Delete all data" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Todos los logros desbloqueados" + "value" : "Borrar todos los datos" } } } }, - "Allow camera access in Settings, or use a device with a camera." : { - - }, - "ALMOST THERE" : { - - }, - "already_have_and_account_label" : { - "comment" : "Already have an account Label", - "extractionState" : "stale", + "delete_data_failed_message" : { + "comment" : "Alert when deletion fails", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Don't have an account?" + "value" : "Couldn't delete your data. Please try again." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "¿No tiene una cuenta?" + "value" : "No se pudieron borrar tus datos. Inténtalo de nuevo." } } } - }, - "Amount" : { - - }, - "amount_placeholder_label" : { - "comment" : "Amount Placeholder Label", + }, + "description_placeholder_label" : { + "comment" : "Description Placeholder Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Amount" + "value" : "Description" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Cantidad" + "value" : "Descripción" } } } }, - "app_preferences_title" : { - "comment" : "App Preferences Title", + "device_not_connected_to_internet_body" : { + "comment" : "Device not connected to internet Body", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "App Preferences" + "value" : "Your device is offline. Features like Tips may not work, but the app is fully functional offline." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Preferencias de la Aplicación" + "value" : "Su dispositivo está desconectado. Funciones como Consejos pueden no funcionar, pero la aplicación sigue siendo completamente funcional sin conexión." } } } }, - "Are you sure you want to delete this expense?" : { - - }, - "Are you sure you want to delete this goal?" : { - - }, - "Are you sure you want to delete this income?" : { - - }, - "AUTO" : { - - }, - "Auto-move on payday" : { - - }, - "Back" : { + "down" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Back" + "value" : "down" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Atrás" + "value" : "abajo" } } } }, - "Back to home" : { - - }, - "Badges unlock as you save — no pressure." : { - - }, - "blue_color" : { - "comment" : "Blue Color", + "download_weekly_report_button" : { + "comment" : "Download Weekly Report Button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Blue" + "value" : "Download Weekly Report" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Azul" + "value" : "Descargar Reporte Semanal" } } } }, - "Camera not available" : { + "e.g. Kyoto, autumn '26" : { }, - "Can't save yet" : { + "Edit anything" : { }, - "Cancel" : { - + "Edit goal" : { + "localizations" : { + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar meta" + } + } + } }, - "cancel_button" : { - "comment" : "Cancel Button", + "email_placeholder_title" : { + "comment" : "Email Placeholder Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Cancel" + "value" : "Email" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Cancelar" + "value" : "Correo Electrónico" } } } }, - "Category" : { - - }, - "category_placeholder" : { - "comment" : "Category Placeholder", - "extractionState" : "extracted_with_value", + "emergency_fund_description" : { + "comment" : "Description for establishing an emergency fund", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Category" + "value" : "You have created an emergency fund worth three months of expenses." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Categoria" + "value" : "Ha creado un fondo de emergencia equivalente a tres meses de gastos." } } } }, - "change_password_button" : { - "comment" : "Change Password Button", + "emergency_fund_title" : { + "comment" : "Title for establishing an emergency fund", "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Change Password" + "value" : "Emergency Fund Established" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Cambiar Contraseña" + "value" : "Fondo de Emergencia Establecido" } } } }, - "Clear name" : { + "end_date_label" : { + "comment" : "End Date Label", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Clear name" + "value" : "End Date" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Borrar nombre" + "value" : "Fecha de Finalización" } } } }, - "Close" : { + "enter_email_and_password_label" : { + "comment" : "Enter your email and password to sign in Label", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Close" + "value" : "Enter your email and password to sign in." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Cerrar" + "value" : "Ingrese su correo electrónico y contraseña para iniciar sesión." } } } }, - "COLOR" : { - - }, - "color_label" : { - "comment" : "Color Label", + "error_invalid_amount" : { + "comment" : "Shown when an amount is empty, not a number, or not positive", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Color" + "value" : "Enter a valid amount greater than zero." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Color" + "value" : "Escribe un monto válido mayor que cero." } } } }, - "Completed" : { + "error_label" : { + "comment" : "Error Label", + "extractionState" : "extracted_with_value", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Error" + } + }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Completadas" + "value" : "Error" } } } }, - "confirm_password_string" : { - "comment" : "Confirm Password String", - "extractionState" : "stale", + "error_save_expense_failed" : { + "comment" : "Shown when writing an expense to the local store fails", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Confirm Password" + "value" : "We couldn't save the expense. Please try again." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Confirmar Contraseña" + "value" : "No pudimos guardar el gasto. Inténtalo de nuevo." } } } }, - "consistent_savings_description" : { - "comment" : "Description for consistent monthly savings", - "extractionState" : "stale", + "Expense" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "You have maintained consistent monthly savings for three months." + "value" : "Expense" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ha mantenido ahorros mensuales constantes durante tres meses." + "value" : "Gasto" } } } }, - "consistent_savings_title" : { - "comment" : "Title for consistent monthly savings", - "extractionState" : "stale", + "Expense amount" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Consistent Monthly Savings" + "value" : "Expense amount" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ahorros Mensuales Constantes" + "value" : "Monto del gasto" } } } }, - "Continue" : { - - }, - "continue_button" : { - "comment" : "Continue Button", + "expense_distribution_label" : { + "comment" : "Expense Distribution Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Continue" + "value" : "Expense Distribution" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Continuar" + "value" : "Distribución de Gastos" } } } }, - "Couldn't save the deposit" : { - - }, - "Couldn't save the goal" : { - - }, - "Create your first goal" : { - - }, - "create_account_button" : { - "comment" : "Create Account Button", + "expense_reminder_body" : { + "comment" : "Expense Reminder Body", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Create Account" + "value" : "Don't forget to log your expenses today!" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Crear una cuenta" + "value" : "¡No olvide registrar sus gastos hoy!" } } } }, - "create_account_title" : { - "comment" : "Create Account Title", - "extractionState" : "stale", + "expense_reminder_picker_title" : { + "comment" : "Select Expense Reminder Time Title", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Create your account!" + "value" : "Select Expense Reminder Time" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "¡Crea tú cuenta!" + "value" : "Seleccione la Hora del Recordatorio de Gastos" } } } }, - "creating_account_label" : { - "comment" : "Creating Account Label", - "extractionState" : "stale", + "expense_reminder_title" : { + "comment" : "Expense Reminder Title", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "By creating an account, I accept Savely's" + "value" : "Expense Reminder" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Al crear una cuenta, acepto los" + "value" : "Recordatorio de Gastos" } } } }, - "dark_mode_label" : { - "comment" : "Dark Mode Label", + "expense_reminders_label" : { + "comment" : "Expense Reminders Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Dark Mode" + "value" : "Expense Reminders" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Modo Oscuro" + "value" : "Recordatorios de Gastos" } } } }, - "dashboard_tab_string" : { - "comment" : "Dashboard Tab String", + "Expenses" : { + + }, + "expenses_tab_string" : { + "comment" : "Expenses Tab String", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Dashboard" + "value" : "Expenses" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Panel" + "value" : "Gastos" } } } }, - "data_privacy_title" : { - "comment" : "Section header", + "export_report_button" : { + "comment" : "Export Report Button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Data & privacy" + "value" : "Export Report" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Datos y privacidad" + "value" : "Exportar Reporte" } } } }, - "data_stays_local_label" : { - "comment" : "One-line intro of the Data & privacy section", + "FAVORITE" : { + + }, + "FAVORITE GOAL" : { + + }, + "favorites_placeholder" : { + "comment" : "Favorites Placeholder", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Your data never leaves this iPhone." + "value" : "Favorites" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Tus datos nunca salen de este iPhone." + "value" : "Favoritos" } } } }, - "Day one" : { - - }, - "debt_paid_off_description" : { - "comment" : "Description for paying off debt", - "extractionState" : "stale", + "fetch_report_button" : { + "comment" : "Fetch Report Button", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "You have paid off a major debt." + "value" : "Fetch Report" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ha pagado una deuda importante." + "value" : "Obtener Reporte" } } } }, - "debt_paid_off_title" : { - "comment" : "Title for paying off debt", - "extractionState" : "stale", + "filter_button" : { + "comment" : "Filter Button", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Debt Paid Off" + "value" : "Filter" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Deuda Saldada" + "value" : "Filtrar" } } } }, - "Decimal point" : { + "first_goal_description" : { + "comment" : "Description for achieving the first goal", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Decimal point" + "value" : "You have reached your first savings goal." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Punto decimal" + "value" : "Ha alcanzado su primera meta de ahorros." } } } }, - "Delete" : { + "first_goal_title" : { + "comment" : "Title for achieving the first goal", + "extractionState" : "stale", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "First Goal Achieved" + } + }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Eliminar" + "value" : "Primera Meta Alcanzada" } } } }, - "Delete Expense" : { - - }, - "Delete Goal" : { - - }, - "Delete Income" : { - - }, - "delete_all_data_confirm_button" : { - "comment" : "Destructive confirm button", - "extractionState" : "extracted_with_value", + "first_investment_description" : { + "comment" : "Description for making the first investment", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Delete everything" + "value" : "You have made your first investment towards your financial future." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Borrar todo" + "value" : "Ha realizado su primera inversión para su futuro financiero." } } } }, - "delete_all_data_confirm_message" : { - "comment" : "Confirmation dialog message", - "extractionState" : "extracted_with_value", + "first_investment_title" : { + "comment" : "Title for making the first investment", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Every income, expense and goal will be removed from this iPhone. Your name and reminder settings stay. This cannot be undone." + "value" : "First Investment Made" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Se eliminarán todos los ingresos, gastos y metas de este iPhone. Tu nombre y tus recordatorios se conservan. Esto no se puede deshacer." + "value" : "Primera Inversión Realizada" } } } }, - "delete_all_data_confirm_title" : { - "comment" : "Confirmation dialog title", - "extractionState" : "extracted_with_value", + "forgot_password_label" : { + "comment" : "Forgot Password Label", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Delete all data?" + "value" : "Forgot password?" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "¿Borrar todos los datos?" + "value" : "¿Olvidó su contraseña?" } } } }, - "delete_all_data_label" : { - "comment" : "Destructive row", - "extractionState" : "extracted_with_value", + "full_name_placeholder" : { + "comment" : "Full Name Placeholder", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Delete all data" + "value" : "Full Name" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Borrar todos los datos" + "value" : "Nombre Completo" } } } }, - "delete_data_failed_message" : { - "comment" : "Alert when deletion fails", + "general_placeholder" : { + "comment" : "General Placeholder", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Couldn't delete your data. Please try again." + "value" : "General" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "No se pudieron borrar tus datos. Inténtalo de nuevo." + "value" : "General" } } } }, - "description_placeholder_label" : { - "comment" : "Description Placeholder Label", + "Give it a name\nyou'll recognize." : { + + }, + "Goal" : { + + }, + "Goal · %@" : { + + }, + "Goal name" : { + + }, + "goal_alert_body" : { + "comment" : "Goal Alert Body", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Description" + "value" : "Check your goals and track your progress!" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Descripción" + "value" : "¡Revise sus metas y siga su progreso!" } } } }, - "device_not_connected_to_internet_body" : { - "comment" : "Device not connected to internet Body", + "goal_alert_picker_title" : { + "comment" : "Select Goal Alert Time Title", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Your device is offline. Features like Tips may not work, but the app is fully functional offline." + "value" : "Select Goal Alert Time" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Su dispositivo está desconectado. Funciones como Consejos pueden no funcionar, pero la aplicación sigue siendo completamente funcional sin conexión." + "value" : "Seleccione la Hora de Alerta de Metas" } } } }, - "down" : { + "goal_alert_title" : { + "comment" : "Goal Alert Title", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "down" + "value" : "Goal Reminder" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "abajo" + "value" : "Recordatorio de Metas" } } } }, - "download_weekly_report_button" : { - "comment" : "Download Weekly Report Button", + "goal_alerts_label" : { + "comment" : "Goal Alerts Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Download Weekly Report" + "value" : "Goal Alerts" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Descargar Reporte Semanal" + "value" : "Alertas de Metas" } } } }, - "e.g. Kyoto, autumn '26" : { - - }, - "Edit anything" : { + "Goals" : { }, - "Edit goal" : { + "goals_tab_string" : { + "comment" : "Goals Tab String", + "extractionState" : "extracted_with_value", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Goals" + } + }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Editar meta" + "value" : "Metas" } } } }, - "email_placeholder_title" : { - "comment" : "Email Placeholder Label", + "green_color" : { + "comment" : "Green Color", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Email" + "value" : "Green" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Correo Electrónico" + "value" : "Verde" } } } }, - "emergency_fund_description" : { - "comment" : "Description for establishing an emergency fund", + "high_savings_rate_description" : { + "comment" : "Description for achieving a high savings rate", "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "You have created an emergency fund worth three months of expenses." + "value" : "You have saved more than 20% of your income this month." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ha creado un fondo de emergencia equivalente a tres meses de gastos." + "value" : "Ha ahorrado más del 20% de sus ingresos este mes." } } } }, - "emergency_fund_title" : { - "comment" : "Title for establishing an emergency fund", + "high_savings_rate_title" : { + "comment" : "Title for achieving a high savings rate", "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Emergency Fund Established" + "value" : "High Savings Rate" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Fondo de Emergencia Establecido" + "value" : "Alta Tasa de Ahorro" } } } }, - "end_date_label" : { - "comment" : "End Date Label", - "extractionState" : "extracted_with_value", + "History" : { "localizations" : { - "en" : { + "es-419" : { "stringUnit" : { - "state" : "new", - "value" : "End Date" + "state" : "translated", + "value" : "Historial" } - }, + } + } + }, + "HOW MUCH" : { + + }, + "Income" : { + "localizations" : { "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Fecha de Finalización" + "value" : "Ingreso" } } } }, - "enter_email_and_password_label" : { - "comment" : "Enter your email and password to sign in Label", - "extractionState" : "stale", + "Income amount" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Enter your email and password to sign in." + "value" : "Income amount" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ingrese su correo electrónico y contraseña para iniciar sesión." + "value" : "Monto del ingreso" } } } }, - "error_label" : { - "comment" : "Error Label", + "income_distribution_label" : { + "comment" : "Income Distribution Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Error" + "value" : "Income Distribution" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Error" + "value" : "Distribución de Ingresos" } } } }, - "Expense" : { + "incomes_tab_string" : { + "comment" : "Incomes Tab String", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Expense" + "value" : "Incomes" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Gasto" + "value" : "Ingresos" } } } }, - "Expense amount" : { + "is_it_rigth_label" : { + "comment" : "Is It Ritght Label", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Expense amount" + "value" : "Is it right?" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Monto del gasto" + "value" : "¿Es correcto?" } } } }, - "expense_distribution_label" : { - "comment" : "Expense Distribution Label", + "just_started_label" : { + "comment" : "Subtitle under the display name when nothing is logged yet", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Expense Distribution" + "value" : "Just getting started" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Distribución de Gastos" + "value" : "Apenas empezando" } } } }, - "expense_reminder_body" : { - "comment" : "Expense Reminder Body", + "LIFETIME INCOME" : { + + }, + "limited_connection_header" : { + "comment" : "Limited Connection Header", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Don't forget to log your expenses today!" + "value" : "Limited Connectivity" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "¡No olvide registrar sus gastos hoy!" + "value" : "Conectividad Limitada" } } } }, - "expense_reminder_picker_title" : { - "comment" : "Select Expense Reminder Time Title", + "Live preview" : { + + }, + "loading_tip_label" : { + "comment" : "Loading Tip Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Select Expense Reminder Time" + "value" : "Loading tip..." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Seleccione la Hora del Recordatorio de Gastos" + "value" : "Cargando consejo..." } } } }, - "expense_reminder_title" : { - "comment" : "Expense Reminder Title", - "extractionState" : "extracted_with_value", + "Log an expense, income, deposit, or new goal" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Expense Reminder" + "value" : "Log an expense, income, deposit, or new goal" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Recordatorio de Gastos" + "value" : "Registra un gasto, un ingreso, un depósito o una meta nueva" } } } }, - "expense_reminders_label" : { - "comment" : "Expense Reminders Label", - "extractionState" : "extracted_with_value", + "Log expense" : { + + }, + "Log income" : { + + }, + "log_in_string" : { + "comment" : "Log In String", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Expense Reminders" + "value" : "Log in to your existant account" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Recordatorios de Gastos" + "value" : "Inicie sesión en su cuenta existente" } } } }, - "Expenses" : { - - }, - "expenses_tab_string" : { - "comment" : "Expenses Tab String", - "extractionState" : "extracted_with_value", + "Long press to delete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Expenses" + "value" : "Long press to delete" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Gastos" + "value" : "Mantén presionado para eliminar" } } } }, - "export_report_button" : { - "comment" : "Export Report Button", - "extractionState" : "extracted_with_value", + "Make favorite" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Export Report" + "value" : "Make favorite" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Exportar Reporte" + "value" : "Marcar como favorita" } } } }, - "FAVORITE" : { - - }, - "FAVORITE GOAL" : { + "Merchant · Today" : { }, - "favorites_placeholder" : { - "comment" : "Favorites Placeholder", - "extractionState" : "extracted_with_value", + "minus %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Favorites" + "value" : "minus %@" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Favoritos" + "value" : "menos %@" } } } }, - "fetch_report_button" : { - "comment" : "Fetch Report Button", - "extractionState" : "extracted_with_value", + "Month over month" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Fetch Report" + "value" : "Month over month" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Obtener Reporte" + "value" : "Mes contra mes" } } } }, - "filter_button" : { - "comment" : "Filter Button", + "monthly_goal_label" : { + "comment" : "Monthly Goal Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Filter" + "value" : "Monthly Goal" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Filtrar" + "value" : "Meta Mensual" } } } }, - "first_goal_description" : { - "comment" : "Description for achieving the first goal", - "extractionState" : "stale", + "monthly_summary_placeholder" : { + "comment" : "Monthly Summary Placeholder", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "You have reached your first savings goal." + "value" : "Monthly Summary" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ha alcanzado su primera meta de ahorros." + "value" : "Resumen Mensual" } } } }, - "first_goal_title" : { - "comment" : "Title for achieving the first goal", - "extractionState" : "stale", + "months_placeholder" : { + "comment" : "Months Placeholder", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "First Goal Achieved" + "value" : "Months" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Primera Meta Alcanzada" + "value" : "Meses" } } } }, - "first_investment_description" : { - "comment" : "Description for making the first investment", - "extractionState" : "stale", + "Move money" : { + + }, + "Move money toward %@." : { + + }, + "NAME" : { + + }, + "name_placeholder_label" : { + "comment" : "Name Placeholder Label", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "You have made your first investment towards your financial future." + "value" : "Name" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ha realizado su primera inversión para su futuro financiero." + "value" : "Nombre" } } } }, - "first_investment_title" : { - "comment" : "Title for making the first investment", - "extractionState" : "stale", + "New" : { + + }, + "New goal" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "First Investment Made" + "value" : "New goal" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Primera Inversión Realizada" + "value" : "Nueva meta" } } } }, - "forgot_password_label" : { - "comment" : "Forgot Password Label", - "extractionState" : "stale", + "New goal · " : { + + }, + "Next month" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Forgot password?" + "value" : "Next month" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "¿Olvidó su contraseña?" + "value" : "Mes siguiente" } } } }, - "full_name_placeholder" : { - "comment" : "Full Name Placeholder", - "extractionState" : "stale", + "next_achievement_label" : { + "comment" : "Kicker above the closest locked achievement", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Full Name" + "value" : "Next up" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Nombre Completo" + "value" : "Siguiente" } } } }, - "general_placeholder" : { - "comment" : "General Placeholder", + "next_button" : { + "comment" : "Next Button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "General" + "value" : "Next" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "General" + "value" : "Siguiente" } } } }, - "Give it a name\nyou'll recognize." : { + "No active goal" : { }, - "Goal" : { + "no date" : { }, - "Goal · %@" : { + "No deposits yet — the first one lands here." : { }, - "Goal name" : { + "No expenses yet" : { }, - "goal_alert_body" : { - "comment" : "Goal Alert Body", + "No goals yet — create one first." : { + + }, + "No income yet" : { + + }, + "No target date — save at your own pace." : { + + }, + "No tip for today yet — check back soon." : { + + }, + "No transactions yet" : { + + }, + "no_button" : { + "comment" : "No Button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Check your goals and track your progress!" + "value" : "No" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "¡Revise sus metas y siga su progreso!" + "value" : "No" } } } }, - "goal_alert_picker_title" : { - "comment" : "Select Goal Alert Time Title", + "no_data_label" : { + "comment" : "No Data Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Select Goal Alert Time" + "value" : "No data available for the selected dates." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Seleccione la Hora de Alerta de Metas" + "value" : "No hay datos disponibles para las fechas seleccionadas." } } } }, - "goal_alert_title" : { - "comment" : "Goal Alert Title", + "no_goal_recently" : { + "comment" : "No Goal Recently Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Goal Reminder" + "value" : "No goals recently" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Recordatorio de Metas" + "value" : "Sin metas recientemente" } } } }, - "goal_alerts_label" : { - "comment" : "Goal Alerts Label", - "extractionState" : "extracted_with_value", + "no_unnecessary_spending_description" : { + "comment" : "Description for avoiding unnecessary spending", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Goal Alerts" + "value" : "You have avoided unnecessary spending for a whole month." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Alertas de Metas" + "value" : "Ha evitado gastos innecesarios durante un mes entero." } } } }, - "Goals" : { - - }, - "goals_tab_string" : { - "comment" : "Goals Tab String", - "extractionState" : "extracted_with_value", + "no_unnecessary_spending_title" : { + "comment" : "Title for avoiding unnecessary spending", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Goals" + "value" : "No Unnecessary Spending" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Metas" + "value" : "Sin Gastos Innecesarios" } } } }, - "green_color" : { - "comment" : "Green Color", + "NOTE" : { + + }, + "Nothing here yet — let's plant the first one." : { + + }, + "notice_label" : { + "comment" : "Notice Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Green" + "value" : "Notice" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Verde" + "value" : "Aviso" } } } }, - "high_savings_rate_description" : { - "comment" : "Description for achieving a high savings rate", - "extractionState" : "stale", + "notification_settings_description" : { + "comment" : "Notification Settings Description", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "You have saved more than 20% of your income this month." + "value" : "Choose the times to receive alerts for expenses and goals." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ha ahorrado más del 20% de sus ingresos este mes." + "value" : "Elija los horarios para recibir alertas sobre gastos y metas." } } } }, - "high_savings_rate_title" : { - "comment" : "Title for achieving a high savings rate", - "extractionState" : "stale", + "notification_settings_title" : { + "comment" : "Notification Settings Title", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "High Savings Rate" + "value" : "Notification Settings" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Alta Tasa de Ahorro" + "value" : "Configuración de Notificaciones" } } } }, - "History" : { + "notifications_denied_hint" : { + "comment" : "Shown instead of the reminder switches when permission is denied", + "extractionState" : "extracted_with_value", "localizations" : { - "es-419" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Historial" + "state" : "new", + "value" : "Notifications are off for Savely in iOS Settings." } - } - } - }, - "HOW MUCH" : { - - }, - "Income" : { - "localizations" : { + }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ingreso" + "value" : "Las notificaciones de Savely están desactivadas en Ajustes de iOS." } } } }, - "Income amount" : { + "notifications_title" : { + "comment" : "Notifications Title", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Income amount" + "value" : "Notifications" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Monto del ingreso" + "value" : "Notificaciones" } } } }, - "income_distribution_label" : { - "comment" : "Income Distribution Label", + "of %@" : { + + }, + "of $%lld" : { + + }, + "OK" : { + + }, + "ok_button" : { + "comment" : "OK Button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Income Distribution" + "value" : "OK" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Distribución de Ingresos" + "value" : "OK" } } } }, - "incomes_tab_string" : { - "comment" : "Incomes Tab String", + "onboarding_welcome_label" : { + "comment" : "Onboarding welcome step description (privacy)", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Incomes" + "value" : "No accounts, no cloud. Your money data lives on your iPhone and never leaves it." } - }, - "es-419" : { + } + } + }, + "onboarding_welcome_title" : { + "comment" : "Onboarding welcome step title (privacy)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Ingresos" + "state" : "new", + "value" : "Yours, and only yours" } } } }, - "is_it_rigth_label" : { - "comment" : "Is It Ritght Label", + "open_settings_button" : { + "comment" : "Button that opens the iOS Settings app", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Is it right?" + "value" : "Open Settings" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "¿Es correcto?" + "value" : "Abrir Ajustes" } } } }, - "just_started_label" : { - "comment" : "Subtitle under the display name when nothing is logged yet", - "extractionState" : "extracted_with_value", + "Optional note" : { + + }, + "other_way_to_connect_label" : { + "comment" : "Other Way to Connect", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Just getting started" + "value" : "Or connect using" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Apenas empezando" + "value" : "O conéctese usando" } } } }, - "LIFETIME INCOME" : { + "PACE" : { }, - "limited_connection_header" : { - "comment" : "Limited Connection Header", - "extractionState" : "extracted_with_value", + "password_string" : { + "comment" : "Password String", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Limited Connectivity" + "value" : "Password" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Conectividad Limitada" + "value" : "Contraseña" } } } }, - "Live preview" : { - - }, - "loading_tip_label" : { - "comment" : "Loading Tip Label", - "extractionState" : "extracted_with_value", + "passwords_dont_match" : { + "comment" : "Passwords Doesnt Match", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Loading tip..." + "value" : "Passwords doesn't match" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Cargando consejo..." + "value" : "Las contraseñas no coinciden" } } } }, - "Log an expense, income, deposit, or new goal" : { + "personal_information_title" : { + "comment" : "Personal Information Title", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Log an expense, income, deposit, or new goal" + "value" : "Personal Information" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Registra un gasto, un ingreso, un depósito o una meta nueva" + "value" : "Información Personal" } } } }, - "Log expense" : { - - }, - "Log income" : { - - }, - "log_in_string" : { - "comment" : "Log In String", - "extractionState" : "stale", + "personalized_placeholder" : { + "comment" : "Personalized Placeholder", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Log in to your existant account" + "value" : "Personalized" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Inicie sesión en su cuenta existente" + "value" : "Personalizados" } } } }, - "Long press to delete" : { + "Pick the amount later in the goal's settings." : { + + }, + "Pick what to log right now." : { + + }, + "pick_date_label" : { + "comment" : "Pick Date Label", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Long press to delete" + "value" : "Pick Date" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Mantén presionado para eliminar" + "value" : "Selecciona Fecha" } } } }, - "Make favorite" : { + "pick_time_period" : { + "comment" : "Pick Time Period Label", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Make favorite" + "value" : "Pick Time Period" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Marcar como favorita" + "value" : "Selecciona Periodo de Tiempo" } } } }, - "Merchant · Today" : { + "Plant goal" : { }, - "minus %@" : { + "Planted." : { + + }, + "please_confirm_value_title" : { + "comment" : "Please Confirm Value Title", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "minus %@" + "value" : "Please Confirm Value" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "menos %@" + "value" : "Por favor confirme el valor" } } } }, - "Month over month" : { + "plus %@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Month over month" + "value" : "plus %@" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Mes contra mes" + "value" : "más %@" } } } }, - "monthly_goal_label" : { - "comment" : "Monthly Goal Label", - "extractionState" : "extracted_with_value", + "Previous month" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Monthly Goal" + "value" : "Previous month" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Meta Mensual" + "value" : "Mes anterior" } } } }, - "monthly_summary_placeholder" : { - "comment" : "Monthly Summary Placeholder", + "previous_tips_title" : { + "comment" : "Previous Tips", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Monthly Summary" + "value" : "Previous Tips" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Resumen Mensual" + "value" : "Consejos Anteriores" } } } }, - "months_placeholder" : { - "comment" : "Months Placeholder", + "profile_tab_string" : { + "comment" : "Profile Tab String", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Months" + "value" : "Profile" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Meses" + "value" : "Perfil" } } } }, - "Move money" : { - - }, - "Move money toward %@." : { - - }, - "NAME" : { - - }, - "name_placeholder_label" : { - "comment" : "Name Placeholder Label", - "extractionState" : "extracted_with_value", + "Progress" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Name" + "value" : "Progress" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Nombre" + "value" : "Progreso" } } } }, - "New" : { + "Progress overall" : { }, - "New goal" : { + "receive_tips_label" : { + "comment" : "Receive Tips Label", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "New goal" + "value" : "Get personalized tips to improve your financial health." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Nueva meta" + "value" : "Obtenga consejos personalizados para mejorar su salud financiera." } } } }, - "New goal · " : { - - }, - "Next month" : { + "receive_tips_title" : { + "comment" : "Receive Tips Title", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Next month" + "value" : "Receive Tips" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Mes siguiente" + "value" : "Recibir Consejos" } } } }, - "next_achievement_label" : { - "comment" : "Kicker above the closest locked achievement", + "Recent" : { + + }, + "recent_expenses_title" : { + "comment" : "Recent Expenses Title", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Next up" + "value" : "Recent Expenses" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Siguiente" + "value" : "Gastos Recientes" } } } }, - "next_button" : { - "comment" : "Next Button", + "recent_incomes_title" : { + "comment" : "Recent Incomes Title", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Next" + "value" : "Recent Incomes" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Siguiente" + "value" : "Ingresos Recientes" } } } }, - "No active goal" : { - - }, - "no date" : { - - }, - "No deposits yet — the first one lands here." : { - - }, - "No expenses yet" : { - - }, - "No goals yet — create one first." : { - - }, - "No income yet" : { - - }, - "No target date — save at your own pace." : { - - }, - "No tip for today yet — check back soon." : { - - }, - "No transactions yet" : { - - }, - "no_button" : { - "comment" : "No Button", + "red_color" : { + "comment" : "Red Color", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "No" + "value" : "Red" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "No" + "value" : "Rojo" } } } }, - "no_data_label" : { - "comment" : "No Data Label", + "reminder_time_label" : { + "comment" : "Label next to the reminder time picker", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "No data available for the selected dates." + "value" : "Time" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "No hay datos disponibles para las fechas seleccionadas." + "value" : "Hora" } } } }, - "no_goal_recently" : { - "comment" : "No Goal Recently Label", - "extractionState" : "extracted_with_value", + "Remove from favorites" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "No goals recently" + "value" : "Remove from favorites" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Sin metas recientemente" + "value" : "Quitar de favoritas" } } } }, - "no_unnecessary_spending_description" : { - "comment" : "Description for avoiding unnecessary spending", - "extractionState" : "stale", + "Reports" : { + + }, + "reports_tab_string" : { + "comment" : "Reports Tab String", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "You have avoided unnecessary spending for a whole month." + "value" : "Reports" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ha evitado gastos innecesarios durante un mes entero." + "value" : "Reportes" } } } }, - "no_unnecessary_spending_title" : { - "comment" : "Title for avoiding unnecessary spending", - "extractionState" : "stale", + "Save" : { + + }, + "Save with intention" : { + + }, + "save_button" : { + "comment" : "Save Button", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "No Unnecessary Spending" + "value" : "Save" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Sin Gastos Innecesarios" + "value" : "Guardar" } } } }, - "NOTE" : { + "SAVED" : { }, - "Nothing here yet — let's plant the first one." : { + "Savely" : { }, - "notice_label" : { - "comment" : "Notice Label", + "savely_app_title" : { + "comment" : "Savely App Title", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Notice" + "value" : "Savely" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Aviso" + "value" : "Savely" } } } }, - "notification_settings_description" : { - "comment" : "Notification Settings Description", + "saving_since_label" : { + "comment" : "Subtitle under the display name; %@ is a month and year", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Choose the times to receive alerts for expenses and goals." + "value" : "Saving since %@" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Elija los horarios para recibir alertas sobre gastos y metas." + "value" : "Ahorrando desde %@" } } } }, - "notification_settings_title" : { - "comment" : "Notification Settings Title", + "savings_placeholder" : { + "comment" : "Savings Placeholcer\nSavings Placeholder", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Notification Settings" + "value" : "Savings" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Configuración de Notificaciones" + "value" : "Ahorros" } } } }, - "notifications_denied_hint" : { - "comment" : "Shown instead of the reminder switches when permission is denied", + "savings_summary_title" : { + "comment" : "Savings Summary Title", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Notifications are off for Savely in iOS Settings." + "value" : "Savings Summary" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Las notificaciones de Savely están desactivadas en Ajustes de iOS." + "value" : "Resumen de Ahorros" } } } }, - "notifications_title" : { - "comment" : "Notifications Title", + "savings_trend_label" : { + "comment" : "Savings Trend Label", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Notifications" + "value" : "Savings Trend" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Notificaciones" + "value" : "Tendencia de Ahorros" } } } }, - "of %@" : { - - }, - "of $%lld" : { - - }, - "OK" : { + "Scan a receipt" : { }, - "ok_button" : { - "comment" : "OK Button", + "scan_alternatives_label" : { + "comment" : "Label above the alternative amount chips", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "OK" + "value" : "Also on the receipt" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "OK" + "value" : "También en el recibo" } } } }, - "onboarding_welcome_label" : { - "comment" : "Onboarding welcome step description (privacy)", + "scan_amount_accessibility_label" : { + "comment" : "Accessibility label of the amount on the review screen", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "No accounts, no cloud. Your money data lives on your iPhone and never leaves it." + "value" : "Expense amount" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monto del gasto" } } } }, - "onboarding_welcome_title" : { - "comment" : "Onboarding welcome step title (privacy)", + "scan_camera_starting_body" : { + "comment" : "Shown when the shutter was tapped before the camera was ready", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Yours, and only yours" + "value" : "The camera is still starting — try again in a moment." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "La cámara aún está iniciando — inténtalo de nuevo en un momento." } } } }, - "open_settings_button" : { - "comment" : "Button that opens the iOS Settings app", + "scan_camera_unavailable_body" : { + "comment" : "Body when the camera cannot start", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Open Settings" + "value" : "Allow camera access in Settings, or choose a receipt photo instead." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Abrir Ajustes" + "value" : "Permite el acceso a la cámara en Ajustes, o elige una foto del recibo." } } } }, - "Optional note" : { - - }, - "other_way_to_connect_label" : { - "comment" : "Other Way to Connect", - "extractionState" : "stale", + "scan_camera_unavailable_title" : { + "comment" : "Shown when the camera cannot start", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Or connect using" + "value" : "Camera not available" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "O conéctese usando" + "value" : "Cámara no disponible" } } } }, - "PACE" : { - - }, - "password_string" : { - "comment" : "Password String", - "extractionState" : "stale", + "scan_close_label" : { + "comment" : "Accessibility label of the close button", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Password" + "value" : "Close" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Contraseña" + "value" : "Cerrar" } } } }, - "passwords_dont_match" : { - "comment" : "Passwords Doesnt Match", - "extractionState" : "stale", + "scan_date_from_receipt_note" : { + "comment" : "Note next to the date when it was read from the receipt", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Passwords doesn't match" + "value" : "from the receipt" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Las contraseñas no coinciden" + "value" : "del recibo" } } } }, - "personal_information_title" : { - "comment" : "Personal Information Title", + "scan_date_label" : { + "comment" : "Label of the date picker on the review screen", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Personal Information" + "value" : "Date" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Información Personal" + "value" : "Fecha" } } } }, - "personalized_placeholder" : { - "comment" : "Personalized Placeholder", + "scan_date_today_note" : { + "comment" : "Note next to the date when it defaulted to today", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Personalized" + "value" : "today — change it if the receipt is older" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Personalizados" + "value" : "hoy — cámbiala si el recibo es de otro día" } } } }, - "Pick the amount later in the goal's settings." : { - - }, - "Pick what to log right now." : { - + "scan_enter_amount_error" : { + "comment" : "Shown when saving with no amount", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enter a valid amount greater than zero." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escribe un monto válido mayor que cero." + } + } + } }, - "pick_date_label" : { - "comment" : "Pick Date Label", + "scan_export_dump_label" : { + "comment" : "DEBUG-only button that shares what OCR read as JSON", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Pick Date" + "value" : "Export OCR dump" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Selecciona Fecha" + "value" : "Exportar OCR (JSON)" } } } }, - "pick_time_period" : { - "comment" : "Pick Time Period Label", + "scan_guide_hint" : { + "comment" : "Hint under the camera guide frame", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Pick Time Period" + "value" : "Frame the whole receipt — flat, in good light." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Selecciona Periodo de Tiempo" + "value" : "Encuadra todo el recibo — plano y con buena luz." } } } }, - "Plant goal" : { - - }, - "Planted." : { - - }, - "please_confirm_value_title" : { - "comment" : "Please Confirm Value Title", + "scan_import_failed_body" : { + "comment" : "Shown when a photo picked from the library could not be loaded", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Please Confirm Value" + "value" : "We couldn't open that photo. Try another one, or take a new picture." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Por favor confirme el valor" + "value" : "No pudimos abrir esa foto. Prueba con otra o toma una nueva." } } } }, - "plus %@" : { + "scan_import_photos_label" : { + "comment" : "Button that opens the photo picker to import a receipt", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "plus %@" + "value" : "Choose from Photos" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "más %@" + "value" : "Elegir de Fotos" } } } }, - "Previous month" : { + "scan_merchant_placeholder" : { + "comment" : "Placeholder of the merchant field on the review screen", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Previous month" + "value" : "Merchant" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Mes anterior" + "value" : "Comercio" } } } }, - "previous_tips_title" : { - "comment" : "Previous Tips", + "scan_open_settings_label" : { + "comment" : "Button that opens the app's Settings page", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Previous Tips" + "value" : "Open Settings" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Consejos Anteriores" + "value" : "Abrir Ajustes" } } } }, - "profile_tab_string" : { - "comment" : "Profile Tab String", + "scan_privacy_note" : { + "comment" : "Reassurance shown in the scanner", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Profile" + "value" : "Read on your iPhone. The photo never leaves your phone." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Perfil" + "value" : "Se lee en tu iPhone. La foto nunca sale de tu teléfono." } } } }, - "Progress" : { + "scan_processing_label" : { + "comment" : "Shown while OCR runs", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Progress" + "value" : "Reading the receipt…" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Progreso" + "value" : "Leyendo el recibo…" } } } }, - "Progress overall" : { - - }, - "receive_tips_label" : { - "comment" : "Receive Tips Label", + "scan_read_failed_body" : { + "comment" : "Body when capture or reading fails", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Get personalized tips to improve your financial health." + "value" : "Try again with the receipt flat and well lit, or enter the amount yourself." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Obtenga consejos personalizados para mejorar su salud financiera." + "value" : "Inténtalo de nuevo con el recibo plano y bien iluminado, o escribe el monto tú mismo." } } } }, - "receive_tips_title" : { - "comment" : "Receive Tips Title", + "scan_read_failed_title" : { + "comment" : "Title when capture or reading fails", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Receive Tips" + "value" : "We couldn't read this one" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Recibir Consejos" + "value" : "No pudimos leer este recibo" } } } }, - "Recent" : { - - }, - "recent_expenses_title" : { - "comment" : "Recent Expenses Title", + "scan_receipt_button" : { + "comment" : "Scan Receipt Button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Recent Expenses" + "value" : "Scan Receipt" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Gastos Recientes" + "value" : "Escanear Recibo" } } } }, - "recent_incomes_title" : { - "comment" : "Recent Incomes Title", + "scan_receipt_thumbnail_label" : { + "comment" : "Accessibility label of the receipt thumbnail", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Recent Incomes" + "value" : "Photo of the receipt" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ingresos Recientes" + "value" : "Foto del recibo" } } } }, - "red_color" : { - "comment" : "Red Color", + "scan_receipts_label" : { + "comment" : "Onboarding receipt scanning step description", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Red" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rojo" + "value" : "Point the camera at a receipt and Savely reads the total for you." } } } }, - "reminder_time_label" : { - "comment" : "Label next to the reminder time picker", + "scan_receipts_title" : { + "comment" : "Onboarding receipt scanning step title", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Time" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hora" + "value" : "Scan Receipts" } } } }, - "Remove from favorites" : { + "scan_retake_label" : { + "comment" : "Button that goes back to the camera", + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Remove from favorites" + "value" : "Retake" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Quitar de favoritas" + "value" : "Repetir" } } } }, - "Reports" : { - - }, - "reports_tab_string" : { - "comment" : "Reports Tab String", + "scan_retry_label" : { + "comment" : "Retry button after a failed read", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Reports" + "value" : "Try again" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Reportes" + "value" : "Intentar de nuevo" } } } }, - "Save" : { - - }, - "Save with intention" : { - - }, - "save_button" : { - "comment" : "Save Button", + "scan_review_lead_none" : { + "comment" : "Lead copy on the review screen when no amount was found", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Save" + "value" : "We couldn't find a total. Enter it below." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Guardar" + "value" : "No encontramos un total. Escríbelo abajo." } } } }, - "SAVED" : { - - }, - "Savely" : { - - }, - "savely_app_title" : { - "comment" : "Savely App Title", + "scan_review_lead_read" : { + "comment" : "Lead copy on the review screen when a total was found", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Savely" + "value" : "We read this — is it right?" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Savely" + "value" : "Leímos esto — ¿está bien?" } } } }, - "saving_since_label" : { - "comment" : "Subtitle under the display name; %@ is a month and year", + "scan_review_lead_unsure" : { + "comment" : "Lead copy on the review screen when the total is a low-confidence guess", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Saving since %@" + "value" : "We're not sure about the total — please check it." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ahorrando desde %@" + "value" : "No estamos seguros del total — revísalo, por favor." } } } }, - "savings_placeholder" : { - "comment" : "Savings Placeholcer\nSavings Placeholder", + "scan_review_title" : { + "comment" : "Title of the review screen after a scan", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Savings" + "value" : "Check the receipt" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ahorros" + "value" : "Revisa el recibo" } } } }, - "savings_summary_title" : { - "comment" : "Savings Summary Title", + "scan_save_expense_label" : { + "comment" : "Primary button on the review screen", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Savings Summary" + "value" : "Save expense" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Resumen de Ahorros" + "value" : "Guardar gasto" } } } }, - "savings_trend_label" : { - "comment" : "Savings Trend Label", + "scan_scanned_receipt_description" : { + "comment" : "Description saved when the receipt had no readable merchant", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Savings Trend" + "value" : "Scanned receipt" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Tendencia de Ahorros" + "value" : "Recibo escaneado" } } } }, - "Scan a receipt" : { - - }, - "scan_receipt_button" : { - "comment" : "Scan Receipt Button", + "scan_shutter_label" : { + "comment" : "Accessibility label of the shutter button", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Scan Receipt" + "value" : "Take photo" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Escanear Recibo" + "value" : "Tomar foto" } } } }, - "scan_receipts_label" : { - "comment" : "Onboarding receipt scanning step description", + "scan_torch_off_label" : { + "comment" : "Accessibility label of the torch button when on", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Point the camera at a receipt and Savely reads the total for you." + "value" : "Turn flash off" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagar flash" } } } }, - "scan_receipts_title" : { - "comment" : "Onboarding receipt scanning step title", + "scan_torch_on_label" : { + "comment" : "Accessibility label of the torch button when off", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Scan Receipts" + "value" : "Turn flash on" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encender flash" } } } @@ -4378,8 +4356,21 @@ } } }, - "We'll read the total and category" : { - + "We'll read the total, merchant and date" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "We'll read the total, merchant and date" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leemos el total, el comercio y la fecha" + } + } + } }, "weekly_budget_description" : { "comment" : "Description for achieving the weekly budget goal",