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 } } }