Files
InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift
T
alexandrev-tibco a8a9ad64f3 Xcode 27: compila sin warnings y migra API deprecadas de SwiftUI
- Imports de CoreData que faltaban (MemberImportVisibility) en AccountsView,
  ImportDataView, ChartsContainerView y AddSourceView.
- Aislamiento MainActor: IsolatedDefaultValues en el target de la app,
  LinearGradient @MainActor, WatchSyncMessage nonisolated, closures de
  ReviewPromptService/MonthlyCheckInStore/AppIntents.
- Warnings de valores sin usar (SnapshotGapDetector, DashboardLayoutStore,
  OnboardingView, GoalEditorView, CoreDataStack, SettingsViewModel).
- foregroundColor→foregroundStyle, cornerRadius→clipShape,
  navigationBarLeading/Trailing→topBarLeading/Trailing,
  Task.sleep(nanoseconds:)→sleep(for:), NavigationLink(isActive:)→
  navigationDestination(isPresented:).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:49 +02:00

395 lines
17 KiB
Swift

import SwiftUI
import CoreData
import TipKit
import UIKit
import PhotosUI
import StoreKit
struct QuickUpdateView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.requestReview) private var requestReview
@Environment(\.managedObjectContext) private var context
@Environment(\.scenePhase) private var scenePhase
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)],
predicate: NSPredicate(format: "isActive == YES"),
animation: .default
) private var sources: FetchedResults<InvestmentSource>
@State private var values: [NSManagedObjectID: String] = [:]
@State private var contributions: [NSManagedObjectID: String] = [:]
@State private var isSaving = false
@State private var saveError: String?
// Clipboard round-trip: copy a value in your bank app, come back, one tap fills
// the next pending source and the focus advances no retyping, no memorizing.
@FocusState private var focusedSource: NSManagedObjectID?
@State private var clipboardAmount: Decimal?
@State private var lastSuggestedRaw: String?
@State private var lastPasteboardChangeCount = -1
// 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
(values[source.objectID] ?? "").trimmingCharacters(in: .whitespaces).isEmpty
}
}
var body: some View {
NavigationStack {
ZStack {
AppBackground()
if sources.isEmpty {
ContentUnavailableView {
Label(String(localized: "quick_update_no_sources"), systemImage: "list.bullet")
} description: {
Text(String(localized: "quick_update_no_sources_body"))
} actions: {
Button {
dismiss()
// Let this sheet finish dismissing before Dashboard
// presents AddSourceView (same pattern as ContentView).
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
NotificationCenter.default.post(name: .openAddFirstSource, object: nil)
}
} label: {
Text(String(localized: "quick_update_add_first_source"))
}
.buttonStyle(.borderedProminent)
}
} else {
List {
Section {
TipView(ScreenshotOCRTip())
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
.listSectionSeparator(.hidden)
if let amount = clipboardAmount, let target = nextEmptySource {
Section {
Button {
applyClipboard(amount, to: target)
} label: {
HStack(spacing: 8) {
Image(systemName: "doc.on.clipboard.fill")
Text(String(
format: String(localized: "quick_update_paste_suggestion"),
amount.currencyString, target.name
))
.multilineTextAlignment(.leading)
Spacer()
Image(systemName: "arrow.down.circle.fill")
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.padding(.vertical, 2)
}
.listRowBackground(Color.appPrimary)
}
}
Section {
ForEach(sources) { source in
sourceRow(source)
}
} header: {
Text(String(localized: "quick_update_section_header"))
} footer: {
Text(String(localized: "quick_update_section_footer"))
}
}
.scrollContentBackground(.hidden)
}
}
.navigationTitle(String(localized: "quick_update_title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "cancel")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "quick_update_save")) {
saveAll()
}
.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 },
set: { if !$0 { saveError = nil } }
)) {
Button("OK", role: .cancel) { saveError = nil }
} message: {
Text(saveError ?? "")
}
.onAppear {
prefillContributions()
checkClipboard()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { checkClipboard() }
}
}
}
/// Clipboard amount for the keyboard toolbar a live text amount, or the
/// amount OCR'd from a copied image (held in clipboardAmount).
private var clipboardFieldAmount: Decimal? {
if let raw = UIPasteboard.general.string,
let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0 {
return parsed
}
return clipboardAmount
}
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) {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(source.name)
.font(.subheadline.weight(.medium))
if source.objectID == nextEmptySource?.objectID {
Text(String(localized: "quick_update_next_badge"))
.font(.caption2.weight(.bold))
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Color.appSecondary.opacity(0.15))
.foregroundStyle(Color.appSecondary)
.clipShape(Capsule())
}
}
if source.latestValue != .zero {
Text(source.latestValue.currencyString)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
TextField(
String(localized: "quick_update_placeholder"),
text: valueBinding(for: source)
)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 120)
.font(.subheadline)
.focused($focusedSource, equals: source.objectID)
}
if contributions[source.objectID] != nil {
HStack {
Text(String(localized: "quick_update_contribution_label"))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
TextField(
String(localized: "quick_update_contribution_placeholder"),
text: contributionBinding(for: source)
)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 120)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private var filledValues: [NSManagedObjectID: String] {
values.filter { !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }
}
private func valueBinding(for source: InvestmentSource) -> Binding<String> {
Binding(
get: { values[source.objectID] ?? "" },
set: { values[source.objectID] = $0 }
)
}
private func contributionBinding(for source: InvestmentSource) -> Binding<String> {
Binding(
get: { contributions[source.objectID] ?? "" },
set: { contributions[source.objectID] = $0 }
)
}
/// Reads the pasteboard and offers a parsed amount for the next pending source.
/// Handles BOTH a copied text value and a copied image (screenshot) the
/// image is OCR'd on-device and its most prominent number offered. Gated by
/// the pasteboard change count so returning with the same clipboard doesn't nag.
private func checkClipboard() {
let pb = UIPasteboard.general
guard pb.changeCount != lastPasteboardChangeCount, nextEmptySource != nil else { return }
lastPasteboardChangeCount = pb.changeCount
// 1. Text amount (cheap, synchronous).
if let raw = pb.string, let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0 {
clipboardAmount = parsed
return
}
// 2. Copied image OCR the amount automatically.
if pb.hasImages, let image = pb.image {
isScanning = true
ImageAmountScanner.scan(image) { candidates in
DispatchQueue.main.async {
isScanning = false
if let best = candidates.first { clipboardAmount = best.value }
}
}
}
}
private func applyClipboard(_ amount: Decimal, to source: InvestmentSource) {
values[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: amount).doubleValue)
clipboardAmount = nil
// Advance focus to the next source still pending, so the user can keep going
// (type directly or hop to the next bank app and come back).
if let next = nextEmptySource {
focusedSource = next.objectID
}
}
private func prefillContributions() {
for source in sources {
if let amount = MonthlyContributionStore.contribution(for: source.id) {
contributions[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: amount).doubleValue)
}
}
}
private func saveAll() {
isSaving = true
let now = Date()
for source in sources {
guard let raw = values[source.objectID],
!raw.trimmingCharacters(in: .whitespaces).isEmpty,
let value = CurrencyFormatter.parseUserInput(raw) else { continue }
let snapshot = Snapshot(context: context)
snapshot.id = UUID()
snapshot.value = NSDecimalNumber(decimal: value)
snapshot.date = now
snapshot.source = source
if let contribRaw = contributions[source.objectID],
!contribRaw.trimmingCharacters(in: .whitespaces).isEmpty,
let contrib = CurrencyFormatter.parseUserInput(contribRaw) {
snapshot.contribution = NSDecimalNumber(decimal: contrib)
}
}
do {
try context.save()
// Reschedule source reminders so they reflect the new snapshots
for source in sources where values[source.objectID].map({ !$0.isEmpty }) == true {
NotificationService.shared.scheduleReminder(for: source)
}
// Keep the share-extension mirror in sync with what was just saved
SharedQuickUpdateSync.refreshMirror()
// Direct context.save() bypasses SnapshotRepository, so refresh the
// widget explicitly otherwise it stays stale until the next app open.
CoreDataStack.shared.refreshWidgetData()
// Positive moment: check-in saved. Ask for a rating once the sheet
// is gone (gated to 2 months of history, once per version).
if ReviewRequestService.shouldRequestAfterCheckIn() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { requestReview() }
}
dismiss()
} catch {
saveError = error.localizedDescription
}
isSaving = false
}
}