6bdf2740c5
#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
96 lines
4.0 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|