1.4.2 (build 48): OCR de screenshots en Share Extension
El extension acepta ahora imágenes además de texto: al compartir un screenshot del banco/broker, Vision reconoce el texto EN EL DISPOSITIVO (la imagen nunca sale del teléfono), extrae los importes candidatos y rellena el más prominente visualmente (en las UIs bancarias el balance es el número más grande). El resto de candidatos se ofrecen como chips de un toque. Percentajes y años se filtran. - ExtImageAmountScanner.swift: VNRecognizeTextRequest + ranking por altura de bounding box + dedup por valor - ShareViewController: carga UIImage/URL/Data del provider, estado de escaneo, sección de candidatos - Info.plist: NSExtensionActivationSupportsImageWithMaxCount=1 - Strings nuevas en los 7 idiomas (ext_scanning_image, ext_detected_amounts, ext_ocr_no_amounts) - PredictionEngine: troceado ternario que agotaba el type-checker en Debug - Bump a 1.4.2 build 48 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
@@ -5,10 +5,11 @@ import UniformTypeIdentifiers
|
||||
// MARK: - Quick Update share extension
|
||||
//
|
||||
// Appears in the share sheet when the user shares/selects text in ANY app (their
|
||||
// bank, broker…). Shows a compact form floating over the host app: pick a source
|
||||
// (the next one pending this month is preselected), amount prefilled from the
|
||||
// shared text, save — and the user never leaves the bank app. The main app turns
|
||||
// these captures into real snapshots on its next activation.
|
||||
// bank, broker…) or shares a screenshot where the balance is visible. Shows a
|
||||
// compact form floating over the host app: pick a source (the next one pending
|
||||
// this month is preselected), amount prefilled from the shared text — or OCR'd
|
||||
// on-device from the image — save, and the user never leaves the bank app. The
|
||||
// main app turns these captures into real snapshots on its next activation.
|
||||
|
||||
@objc(ShareViewController)
|
||||
final class ShareViewController: UIViewController {
|
||||
@@ -16,33 +17,53 @@ final class ShareViewController: UIViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
extractSharedText { [weak self] text in
|
||||
extractSharedContent { [weak self] text, image in
|
||||
DispatchQueue.main.async {
|
||||
self?.presentForm(sharedText: text)
|
||||
self?.presentForm(sharedText: text, sharedImage: image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func extractSharedText(completion: @escaping (String?) -> Void) {
|
||||
private func extractSharedContent(completion: @escaping (String?, UIImage?) -> Void) {
|
||||
let providers = (extensionContext?.inputItems as? [NSExtensionItem])?
|
||||
.compactMap { $0.attachments }
|
||||
.flatMap { $0 } ?? []
|
||||
|
||||
guard let provider = providers.first(where: {
|
||||
if let provider = providers.first(where: {
|
||||
$0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier)
|
||||
}) else {
|
||||
completion(nil)
|
||||
}) {
|
||||
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
|
||||
completion(item as? String, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
|
||||
completion(item as? String)
|
||||
if let provider = providers.first(where: {
|
||||
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
|
||||
}) {
|
||||
provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { item, _ in
|
||||
completion(nil, Self.image(from: item))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
completion(nil, nil)
|
||||
}
|
||||
|
||||
/// Share sheets deliver images as UIImage, file URL, or raw Data depending on the host app.
|
||||
private static func image(from item: NSSecureCoding?) -> UIImage? {
|
||||
switch item {
|
||||
case let image as UIImage: return image
|
||||
case let url as URL: return (try? Data(contentsOf: url)).flatMap(UIImage.init(data:))
|
||||
case let data as Data: return UIImage(data: data)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func presentForm(sharedText: String?) {
|
||||
private func presentForm(sharedText: String?, sharedImage: UIImage?) {
|
||||
let form = QuickUpdateShareView(
|
||||
sharedText: sharedText,
|
||||
sharedImage: sharedImage,
|
||||
onDone: { [weak self] in
|
||||
self?.extensionContext?.completeRequest(returningItems: nil)
|
||||
},
|
||||
@@ -68,6 +89,7 @@ final class ShareViewController: UIViewController {
|
||||
|
||||
struct QuickUpdateShareView: View {
|
||||
let sharedText: String?
|
||||
let sharedImage: UIImage?
|
||||
let onDone: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@@ -75,6 +97,9 @@ struct QuickUpdateShareView: View {
|
||||
@State private var selectedSourceId: UUID?
|
||||
@State private var amountText: String = ""
|
||||
@State private var saved = false
|
||||
@State private var scanning = false
|
||||
@State private var candidates: [ExtAmountCandidate] = []
|
||||
@State private var scanFoundNothing = false
|
||||
@FocusState private var amountFocused: Bool
|
||||
|
||||
private var parsedAmount: Decimal? {
|
||||
@@ -117,10 +142,59 @@ struct QuickUpdateShareView: View {
|
||||
private var form: some View {
|
||||
Form {
|
||||
Section(String(localized: "ext_amount_section")) {
|
||||
TextField(String(localized: "ext_amount_placeholder"), text: $amountText)
|
||||
.keyboardType(.decimalPad)
|
||||
.font(.title3.weight(.semibold))
|
||||
.focused($amountFocused)
|
||||
if scanning {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text(String(localized: "ext_scanning_image"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
TextField(String(localized: "ext_amount_placeholder"), text: $amountText)
|
||||
.keyboardType(.decimalPad)
|
||||
.font(.title3.weight(.semibold))
|
||||
.focused($amountFocused)
|
||||
if scanFoundNothing {
|
||||
Text(String(localized: "ext_ocr_no_amounts"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.count > 1 {
|
||||
Section(String(localized: "ext_detected_amounts")) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(candidates) { candidate in
|
||||
Button {
|
||||
amountText = "\(candidate.value)"
|
||||
} label: {
|
||||
Text(candidate.display)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
Capsule().fill(
|
||||
amountText == "\(candidate.value)"
|
||||
? Color.blue.opacity(0.18)
|
||||
: Color(.tertiarySystemFill)
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
Capsule().strokeBorder(
|
||||
amountText == "\(candidate.value)" ? Color.blue : .clear,
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(String(localized: "ext_source_section")) {
|
||||
@@ -181,11 +255,30 @@ struct QuickUpdateShareView: View {
|
||||
|
||||
if let sharedText, let parsed = ExtAmountParser.parse(sharedText) {
|
||||
amountText = "\(parsed)"
|
||||
} else if let sharedImage {
|
||||
scanImage(sharedImage)
|
||||
} else {
|
||||
amountFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
private func scanImage(_ image: UIImage) {
|
||||
scanning = true
|
||||
ExtImageAmountScanner.scan(image) { found in
|
||||
DispatchQueue.main.async {
|
||||
scanning = false
|
||||
candidates = found
|
||||
if let best = found.first {
|
||||
// Most visually prominent number — in bank UIs, the balance.
|
||||
amountText = "\(best.value)"
|
||||
} else {
|
||||
scanFoundNothing = true
|
||||
amountFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let amount = parsedAmount, let sourceId = selectedSourceId else { return }
|
||||
ExtSharedStore.appendPending(ExtPendingUpdate(
|
||||
|
||||
Reference in New Issue
Block a user