#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
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user