386f4ff265
El dolor: para actualizar cada source hay que saltar a la app del banco, copiar/ memorizar el valor y volver. iOS no permite ventanas flotantes editables (PiP es solo vídeo), así que se resuelve con las dos vías sancionadas: Share Extension (target nuevo PortfolioJournalQuickUpdate): - Al compartir texto desde CUALQUIER app aparece "Portfolio Journal": mini-form sobre la app anfitriona con el importe pre-parseado del texto compartido y la siguiente source pendiente del mes preseleccionada. Guardar → sigues en el banco. - Sin Core Data en la extensión: la app publica un espejo de sources activas en el App Group (SharedQuickUpdateSync.refreshMirror) y la extensión encola PendingQuickUpdate; la app los convierte en Snapshots reales al activarse (ingestPending). ExtSharedBridge duplica el contrato (keep-in-sync comentado). - Parser de importes tolerante a locales (12.345,67 € / $1,234.56 / 1234,5). - UI localizada en los 7 idiomas (lproj propios de la extensión). - pbxproj: target app-extension completo (sync group, embed, dependency, configs) replicando el patrón del widget. Entitlements solo con App Group. Clipboard auto-advance en Quick Update: - Al volver a la app con un importe copiado: banner de un tap "Pegar X en <source>" que rellena la siguiente source vacía y avanza el foco a la siguiente. - Badge NEXT en la fila activa; no repite sugerencias del mismo clipboard. Script de upload: -allowProvisioningUpdates + API key también en el archive para que el bundle id nuevo de la extensión se registre automáticamente. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
199 lines
7.1 KiB
Swift
199 lines
7.1 KiB
Swift
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…). 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, 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
|
|
extractSharedText { [weak self] text in
|
|
DispatchQueue.main.async {
|
|
self?.presentForm(sharedText: text)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func extractSharedText(completion: @escaping (String?) -> Void) {
|
|
let providers = (extensionContext?.inputItems as? [NSExtensionItem])?
|
|
.compactMap { $0.attachments }
|
|
.flatMap { $0 } ?? []
|
|
|
|
guard let provider = providers.first(where: {
|
|
$0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier)
|
|
}) else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
|
|
completion(item as? String)
|
|
}
|
|
}
|
|
|
|
private func presentForm(sharedText: String?) {
|
|
let form = QuickUpdateShareView(
|
|
sharedText: sharedText,
|
|
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 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
|
|
@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")) {
|
|
TextField(String(localized: "ext_amount_placeholder"), text: $amountText)
|
|
.keyboardType(.decimalPad)
|
|
.font(.title3.weight(.semibold))
|
|
.focused($amountFocused)
|
|
}
|
|
|
|
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 {
|
|
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 }
|
|
}
|
|
}
|