19b1408c55
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
292 lines
11 KiB
Swift
292 lines
11 KiB
Swift
import UIKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
// MARK: - Quick Update share extension
|
|
//
|
|
// Appears in the share sheet when the user shares/selects text in ANY app (their
|
|
// 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 {
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .clear
|
|
extractSharedContent { [weak self] text, image in
|
|
DispatchQueue.main.async {
|
|
self?.presentForm(sharedText: text, sharedImage: image)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func extractSharedContent(completion: @escaping (String?, UIImage?) -> Void) {
|
|
let providers = (extensionContext?.inputItems as? [NSExtensionItem])?
|
|
.compactMap { $0.attachments }
|
|
.flatMap { $0 } ?? []
|
|
|
|
if let provider = providers.first(where: {
|
|
$0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier)
|
|
}) {
|
|
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
|
|
completion(item as? String, nil)
|
|
}
|
|
return
|
|
}
|
|
|
|
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?, sharedImage: UIImage?) {
|
|
let form = QuickUpdateShareView(
|
|
sharedText: sharedText,
|
|
sharedImage: sharedImage,
|
|
onDone: { [weak self] in
|
|
self?.extensionContext?.completeRequest(returningItems: nil)
|
|
},
|
|
onCancel: { [weak self] in
|
|
self?.extensionContext?.cancelRequest(withError: NSError(
|
|
domain: "com.alexandrevazquez.PortfolioJournal.QuickUpdate",
|
|
code: NSUserCancelledError
|
|
))
|
|
}
|
|
)
|
|
let host = UIHostingController(rootView: form)
|
|
host.modalPresentationStyle = .formSheet
|
|
if let sheet = host.sheetPresentationController {
|
|
sheet.detents = [.medium(), .large()]
|
|
sheet.prefersGrabberVisible = true
|
|
}
|
|
host.isModalInPresentation = true
|
|
present(host, animated: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Form
|
|
|
|
struct QuickUpdateShareView: View {
|
|
let sharedText: String?
|
|
let sharedImage: UIImage?
|
|
let onDone: () -> Void
|
|
let onCancel: () -> Void
|
|
|
|
@State private var sources: [ExtSourceInfo] = []
|
|
@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? {
|
|
ExtAmountParser.parse(amountText)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if sources.isEmpty {
|
|
ContentUnavailableView(
|
|
String(localized: "ext_no_sources_title"),
|
|
systemImage: "list.bullet",
|
|
description: Text(String(localized: "ext_no_sources_body"))
|
|
)
|
|
} else if saved {
|
|
savedConfirmation
|
|
} else {
|
|
form
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "ext_title"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(String(localized: "ext_cancel")) { onCancel() }
|
|
}
|
|
if !sources.isEmpty && !saved {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(String(localized: "ext_save")) { save() }
|
|
.disabled(parsedAmount == nil || selectedSourceId == nil)
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear(perform: load)
|
|
}
|
|
|
|
private var form: some View {
|
|
Form {
|
|
Section(String(localized: "ext_amount_section")) {
|
|
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")) {
|
|
ForEach(sources) { source in
|
|
Button {
|
|
selectedSourceId = source.id
|
|
} label: {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(source.name)
|
|
.foregroundColor(.primary)
|
|
.font(.subheadline.weight(.medium))
|
|
if source.updatedThisMonth {
|
|
Text(String(localized: "ext_already_updated"))
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
if selectedSourceId == source.id {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var savedConfirmation: some View {
|
|
VStack(spacing: 14) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 52))
|
|
.foregroundColor(.green)
|
|
Text(String(localized: "ext_saved_title"))
|
|
.font(.headline)
|
|
Text(String(localized: "ext_saved_body"))
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(32)
|
|
.onAppear {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { onDone() }
|
|
}
|
|
}
|
|
|
|
private func load() {
|
|
var mirror = ExtSharedStore.readMirror()
|
|
// Pending-first ordering: the sources still waiting this month come on top.
|
|
mirror.sort { a, b in
|
|
if a.updatedThisMonth != b.updatedThisMonth { return !a.updatedThisMonth }
|
|
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
|
|
}
|
|
sources = mirror
|
|
selectedSourceId = mirror.first(where: { !$0.updatedThisMonth })?.id ?? mirror.first?.id
|
|
|
|
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(
|
|
sourceId: sourceId,
|
|
amount: NSDecimalNumber(decimal: amount).doubleValue,
|
|
capturedAt: Date()
|
|
))
|
|
withAnimation { saved = true }
|
|
}
|
|
}
|