Files
InvestmentTrackerApp/PortfolioJournal/Utilities/ImageAmountScanner.swift
T
alexandrev-tibco 6bdf2740c5 Feedback #3 y #4: pegar/OCR por campo en Quick Update + swipe entre charts (build 60)
#3 Quick Update paste/scan por campo:
- Barra de teclado con 'Pegar €X' (si hay importe en portapapeles) y 'Escanear'
  (PhotosPicker → OCR on-device). Actúan sobre el campo enfocado.
- ImageAmountScanner nuevo (app target, Vision) espejo del scanner del Share
  Extension: candidatos rankeados por prominencia, filtra % y años.
- 1 candidato → rellena directo; varios → confirmationDialog para elegir.
- Strings ×7.

#4 Swipe entre charts (iPhone):
- Gesto horizontal en el área del chart avanza al chart anterior/siguiente en
  el orden del menú de título (respeta showForecast). Indicador de página con
  puntos. Transición direccional. selectChart ya no bloquea premium (muestra
  teaser), así que deslizar a uno premium funciona.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 21:37:21 +02:00

96 lines
4.0 KiB
Swift

import Foundation
import UIKit
import Vision
// MARK: - On-device amount OCR (app target)
//
// Recognizes the monetary amounts in a shared/picked image entirely on-device
// (Vision), ranked by visual prominence the balance is almost always the
// biggest number on a bank screen. Mirrors the Share Extension's scanner so the
// two entry points behave identically.
struct ScannedAmount: Identifiable, Equatable {
let id = UUID()
let display: String
let value: Decimal
let prominence: CGFloat
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
}
enum ImageAmountScanner {
static let maxCandidates = 6
static func scan(_ image: UIImage, completion: @escaping ([ScannedAmount]) -> Void) {
guard let cgImage = image.cgImage else { completion([]); return }
let request = VNRecognizeTextRequest { request, _ in
let obs = (request.results as? [VNRecognizedTextObservation]) ?? []
completion(candidates(from: obs))
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = false
DispatchQueue.global(qos: .userInitiated).async {
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: cgOrientation(from: image.imageOrientation))
try? handler.perform([request])
}
}
private static let numberPattern: NSRegularExpression = {
let pattern = "\\d{1,3}(?:[.,\u{00A0} ]\\d{3})+(?:[.,]\\d{1,2})?|\\d+[.,]\\d{1,2}|\\d+"
return try! NSRegularExpression(pattern: pattern)
}()
private static func candidates(from observations: [VNRecognizedTextObservation]) -> [ScannedAmount] {
var found: [ScannedAmount] = []
for observation in observations {
guard let recognized = observation.topCandidates(1).first else { continue }
let line = recognized.string
let range = NSRange(line.startIndex..., in: line)
for match in numberPattern.matches(in: line, range: range) {
guard let r = Range(match.range, in: line) else { continue }
let display = String(line[r])
guard !isPercentage(display, in: line, at: r),
!looksLikeYear(display),
let value = CurrencyFormatter.parseUserInput(display), value > 0 else { continue }
found.append(ScannedAmount(
display: display.replacingOccurrences(of: "\u{00A0}", with: " "),
value: value,
prominence: observation.boundingBox.height))
}
}
var byValue: [Decimal: ScannedAmount] = [:]
for c in found {
if let e = byValue[c.value], e.prominence >= c.prominence { continue }
byValue[c.value] = c
}
return byValue.values.sorted { $0.prominence > $1.prominence }.prefix(maxCandidates).map { $0 }
}
private static func isPercentage(_ display: String, in line: String, at range: Range<String.Index>) -> Bool {
let after = line[range.upperBound...].drop(while: { $0 == " " || $0 == "\u{00A0}" })
if after.first == "%" { return true }
let before = line[..<range.lowerBound].reversed().drop(while: { $0 == " " || $0 == "\u{00A0}" })
return before.first == "%"
}
private static func looksLikeYear(_ display: String) -> Bool {
guard display.count == 4, display.allSatisfy(\.isNumber), let v = Int(display) else { return false }
return (1990...2099).contains(v)
}
private static func cgOrientation(from o: UIImage.Orientation) -> CGImagePropertyOrientation {
switch o {
case .up: return .up
case .down: return .down
case .left: return .left
case .right: return .right
case .upMirrored: return .upMirrored
case .downMirrored: return .downMirrored
case .leftMirrored: return .leftMirrored
case .rightMirrored: return .rightMirrored
@unknown default: return .up
}
}
}