Actualización sin cambiar de app: Share Extension "Quick Update" + clipboard auto-advance
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
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user