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
@@ -604,6 +604,14 @@ struct ChartsContainerView: View {
}
iPhonePeriodControl
chartContent
// Swipe left/right to move to the adjacent chart type.
.gesture(chartSwipeGesture)
.id(viewModel.selectedChartType)
.transition(.asymmetric(
insertion: .move(edge: swipeInsertionEdge).combined(with: .opacity),
removal: .opacity
))
chartPageIndicator
if viewModel.hiddenHistoryMonths > 0 {
lockedHistoryTeaser
}
@@ -614,6 +622,45 @@ struct ChartsContainerView: View {
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
// MARK: - iPhone swipe between charts
/// Chart types in the same grouped order as the title switcher menu.
private var orderedChartTypes: [ChartsViewModel.ChartType] {
Self.chartGroups.flatMap { $0.types }.filter { showForecast || $0 != .prediction }
}
@State private var swipeInsertionEdge: Edge = .trailing
private var chartSwipeGesture: some Gesture {
DragGesture(minimumDistance: 30)
.onEnded { value in
// Horizontal-dominant swipes only, so vertical scroll still works.
guard abs(value.translation.width) > abs(value.translation.height) * 1.5 else { return }
let ordered = orderedChartTypes
guard let idx = ordered.firstIndex(of: viewModel.selectedChartType) else { return }
if value.translation.width < 0, idx < ordered.count - 1 {
swipeInsertionEdge = .trailing
withAnimation(.snappy) { viewModel.selectChart(ordered[idx + 1]) }
} else if value.translation.width > 0, idx > 0 {
swipeInsertionEdge = .leading
withAnimation(.snappy) { viewModel.selectChart(ordered[idx - 1]) }
}
}
}
private var chartPageIndicator: some View {
let ordered = orderedChartTypes
let current = ordered.firstIndex(of: viewModel.selectedChartType) ?? 0
return HStack(spacing: 5) {
ForEach(ordered.indices, id: \.self) { i in
Circle()
.fill(i == current ? Color.appPrimary : Color.secondary.opacity(0.25))
.frame(width: i == current ? 7 : 5, height: i == current ? 7 : 5)
}
}
.animation(.snappy, value: current)
}
/// Stocks-style segmented period control above the chart (the chart switcher
/// lives in the title menu, filters in the toolbar).
@ViewBuilder
@@ -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) {