#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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user