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
This commit is contained in:
alexandrev-tibco
2026-07-10 21:37:21 +02:00
parent 86c95a9e73
commit 6bdf2740c5
11 changed files with 301 additions and 10 deletions
@@ -2,6 +2,7 @@ import SwiftUI
import CoreData
import TipKit
import UIKit
import PhotosUI
struct QuickUpdateView: View {
@Environment(\.dismiss) private var dismiss
@@ -24,6 +25,14 @@ struct QuickUpdateView: View {
@State private var clipboardAmount: Decimal?
@State private var lastSuggestedRaw: String?
// Per-field paste/scan (feedback #3): keyboard toolbar acts on the focused
// field paste a clipboard amount or OCR a number from a screenshot.
@State private var photoItem: PhotosPickerItem?
@State private var showingPhotoPicker = false
@State private var scanCandidates: [ScannedAmount] = []
@State private var isScanning = false
@State private var scanTargetSource: NSManagedObjectID?
/// First source (list order) still without a value the "active" one.
private var nextEmptySource: InvestmentSource? {
sources.first { source in
@@ -100,6 +109,51 @@ struct QuickUpdateView: View {
.disabled(isSaving || filledValues.isEmpty)
.fontWeight(.semibold)
}
// Keyboard accessory: paste / scan for the focused field.
ToolbarItemGroup(placement: .keyboard) {
if let clip = clipboardFieldAmount {
Button {
if let target = focusedSource { values[target] = decimalInputString(clip) }
} label: {
Label(String(format: String(localized: "quick_update_paste_value"), clip.currencyString),
systemImage: "doc.on.clipboard")
}
}
Button {
scanTargetSource = focusedSource
showingPhotoPicker = true
} label: {
Label(String(localized: "quick_update_scan"), systemImage: "text.viewfinder")
}
Spacer()
Button(String(localized: "quick_update_done_kbd")) { focusedSource = nil }
.fontWeight(.semibold)
}
}
.photosPicker(isPresented: $showingPhotoPicker, selection: $photoItem, matching: .images)
.onChange(of: photoItem) { _, item in
guard let item else { return }
Task { await scanPickedPhoto(item) }
}
.overlay {
if isScanning {
ZStack {
Color.black.opacity(0.2).ignoresSafeArea()
ProgressView(String(localized: "quick_update_scanning"))
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
}
.confirmationDialog(
String(localized: "quick_update_pick_amount"),
isPresented: Binding(get: { scanCandidates.count > 1 }, set: { if !$0 { scanCandidates = [] } }),
titleVisibility: .visible
) {
ForEach(scanCandidates) { c in
Button(c.value.currencyString) { applyScanned(c.value) }
}
Button(String(localized: "cancel"), role: .cancel) { scanCandidates = [] }
}
.alert("Error", isPresented: Binding(
get: { saveError != nil },
@@ -119,6 +173,45 @@ struct QuickUpdateView: View {
}
}
/// Clipboard amount for the keyboard toolbar (independent of the top banner's
/// one-shot suggestion state).
private var clipboardFieldAmount: Decimal? {
guard let raw = UIPasteboard.general.string,
let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0 else { return nil }
return parsed
}
private func decimalInputString(_ value: Decimal) -> String {
String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue)
}
private func applyScanned(_ value: Decimal) {
if let target = scanTargetSource ?? focusedSource {
values[target] = decimalInputString(value)
}
scanCandidates = []
}
@MainActor
private func scanPickedPhoto(_ item: PhotosPickerItem) async {
isScanning = true
defer { photoItem = nil }
guard let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else { isScanning = false; return }
ImageAmountScanner.scan(image) { candidates in
DispatchQueue.main.async {
isScanning = false
if candidates.count == 1 {
applyScanned(candidates[0].value)
} else if candidates.count > 1 {
scanCandidates = candidates
} else {
saveError = String(localized: "quick_update_no_amount_found")
}
}
}
}
@ViewBuilder
private func sourceRow(_ source: InvestmentSource) -> some View {
VStack(spacing: 6) {