diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index f48459c..231516d 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E53752E2F0FD09F00F31390 /* CoreData.framework */; }; 0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375302F0FD12E00F31390 /* GoogleMobileAds */; }; 0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375342F0FD14000F31390 /* FirebaseCrashlytics */; }; + 0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -39,6 +40,13 @@ remoteGlobalIDString = 0E241E382F0DA93A00283E2F; remoteInfo = PortfolioJournal; }; + 0EQUPD112F40000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0E241E312F0DA93A00283E2F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0EQUPD022F40000000000001; + remoteInfo = PortfolioJournalQuickUpdate; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -49,6 +57,7 @@ dstSubfolderSpec = 13; files = ( 0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */, + 0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -64,6 +73,8 @@ 0E53752E2F0FD09F00F31390 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = System/Library/Frameworks/CoreData.framework; sourceTree = SDKROOT; }; 0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PortfolioJournalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PortfolioJournalUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalQuickUpdate.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0EQUPD142F40000000000001 /* PortfolioJournalQuickUpdateExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PortfolioJournalQuickUpdateExtension.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -88,6 +99,13 @@ ); target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */; }; + 0EQUPD132F40000000000001 /* Exceptions for "PortfolioJournalQuickUpdate" folder in "PortfolioJournalQuickUpdate" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -118,6 +136,14 @@ path = PortfolioJournalUITests; sourceTree = ""; }; + 0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 0EQUPD132F40000000000001 /* Exceptions for "PortfolioJournalQuickUpdate" folder in "PortfolioJournalQuickUpdate" target */, + ); + path = PortfolioJournalQuickUpdate; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -156,6 +182,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 0EQUPD052F40000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -163,8 +196,10 @@ isa = PBXGroup; children = ( 0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */, + 0EQUPD142F40000000000001 /* PortfolioJournalQuickUpdateExtension.entitlements */, 0E241E3B2F0DA93A00283E2F /* PortfolioJournal */, 0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */, + 0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */, 0ETEST0032F31000000000001 /* PortfolioJournalTests */, 0EUITEST032F31000000001 /* PortfolioJournalUITests */, 0E241ECD2F0DAA3C00283E2F /* Frameworks */, @@ -177,6 +212,7 @@ children = ( 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */, 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */, + 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */, 0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */, 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */, ); @@ -210,6 +246,7 @@ ); dependencies = ( 0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */, + 0EQUPD122F40000000000001 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 0E241E3B2F0DA93A00283E2F /* PortfolioJournal */, @@ -293,6 +330,28 @@ productReference = 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0EQUPD072F40000000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalQuickUpdate" */; + buildPhases = ( + 0EQUPD042F40000000000001 /* Sources */, + 0EQUPD052F40000000000001 /* Frameworks */, + 0EQUPD062F40000000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */, + ); + name = PortfolioJournalQuickUpdate; + packageProductDependencies = ( + ); + productName = PortfolioJournalQuickUpdate; + productReference = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -309,6 +368,9 @@ 0E241ECB2F0DAA3C00283E2F = { CreatedOnToolsVersion = 26.2; }; + 0EQUPD022F40000000000001 = { + CreatedOnToolsVersion = 26.2; + }; 0ETEST0012F31000000000001 = { CreatedOnToolsVersion = 26.2; TestTargetID = 0E241E382F0DA93A00283E2F; @@ -341,6 +403,7 @@ targets = ( 0E241E382F0DA93A00283E2F /* PortfolioJournal */, 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */, + 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */, 0ETEST0012F31000000000001 /* PortfolioJournalTests */, 0EUITEST012F31000000001 /* PortfolioJournalUITests */, ); @@ -376,6 +439,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 0EQUPD062F40000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -407,6 +477,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 0EQUPD042F40000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -448,6 +525,11 @@ target = 0E241E382F0DA93A00283E2F /* PortfolioJournal */; targetProxy = 0E481F2D2F2E958100CF94C5 /* PBXContainerItemProxy */; }; + 0EQUPD122F40000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */; + targetProxy = 0EQUPD112F40000000000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -808,6 +890,60 @@ }; name = Release; }; + 0EQUPD082F40000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 46; + DEVELOPMENT_TEAM = 2825Q76T7H; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.4.1; + PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 0EQUPD092F40000000000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 46; + DEVELOPMENT_TEAM = 2825Q76T7H; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.4.1; + PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -856,6 +992,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 0EQUPD072F40000000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalQuickUpdate" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0EQUPD082F40000000000001 /* Debug */, + 0EQUPD092F40000000000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/PortfolioJournal/App/PortfolioJournalApp.swift b/PortfolioJournal/App/PortfolioJournalApp.swift index 15b5e07..c4cbfb1 100644 --- a/PortfolioJournal/App/PortfolioJournalApp.swift +++ b/PortfolioJournal/App/PortfolioJournalApp.swift @@ -73,6 +73,12 @@ struct PortfolioJournalApp: App { NotificationService.shared.scheduleReEngagementNotification() NotificationService.shared.scheduleMonthlyCheckIn() NotificationService.shared.scheduleStreakProtectionReminder() + // Share-extension bridge: apply values captured from other apps and + // refresh the source mirror the extension reads. + if coreDataStack.isLoaded { + SharedQuickUpdateSync.ingestPending() + SharedQuickUpdateSync.refreshMirror() + } } else if newPhase == .background { guard iapService.isPremium else { return } guard UserDefaults.standard.bool(forKey: "backupsEnabled") else { return } diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift index 9114f9b..615863b 100644 --- a/PortfolioJournal/Models/CoreDataStack.swift +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -192,6 +192,11 @@ class CoreDataStack: ObservableObject { AllocationTargetStore.migrateIfNeeded(context: container.viewContext) // Seed demo data when running in screenshot capture mode. ScreenshotMode.seedIfNeeded() + // Apply share-extension captures and publish the source mirror. + Task { @MainActor in + SharedQuickUpdateSync.ingestPending() + SharedQuickUpdateSync.refreshMirror() + } } } diff --git a/PortfolioJournal/Resources/de.lproj/Localizable.strings b/PortfolioJournal/Resources/de.lproj/Localizable.strings index 877a16e..3a88c8e 100644 --- a/PortfolioJournal/Resources/de.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/de.lproj/Localizable.strings @@ -446,3 +446,6 @@ "chart_zoom_in" = "Vergrößern"; "chart_zoom_out" = "Verkleinern"; "chart_zoom_reset" = "Zoom zurücksetzen"; + +"quick_update_paste_suggestion" = "%@ in %@ einfügen"; +"quick_update_next_badge" = "NÄCHSTE"; diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index 9d54971..fb584bb 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -454,3 +454,6 @@ "chart_zoom_in" = "Zoom in"; "chart_zoom_out" = "Zoom out"; "chart_zoom_reset" = "Reset zoom"; + +"quick_update_paste_suggestion" = "Paste %@ into %@"; +"quick_update_next_badge" = "NEXT"; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index 84979c4..14b1469 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -398,3 +398,6 @@ "chart_zoom_in" = "Acercar"; "chart_zoom_out" = "Alejar"; "chart_zoom_reset" = "Restablecer zoom"; + +"quick_update_paste_suggestion" = "Pegar %@ en %@"; +"quick_update_next_badge" = "SIGUIENTE"; diff --git a/PortfolioJournal/Resources/fr.lproj/Localizable.strings b/PortfolioJournal/Resources/fr.lproj/Localizable.strings index 854c093..660ce6a 100644 --- a/PortfolioJournal/Resources/fr.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/fr.lproj/Localizable.strings @@ -446,3 +446,6 @@ "chart_zoom_in" = "Zoom avant"; "chart_zoom_out" = "Zoom arrière"; "chart_zoom_reset" = "Réinitialiser le zoom"; + +"quick_update_paste_suggestion" = "Coller %@ dans %@"; +"quick_update_next_badge" = "SUIVANTE"; diff --git a/PortfolioJournal/Resources/it.lproj/Localizable.strings b/PortfolioJournal/Resources/it.lproj/Localizable.strings index b2c4487..669fb30 100644 --- a/PortfolioJournal/Resources/it.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/it.lproj/Localizable.strings @@ -446,3 +446,6 @@ "chart_zoom_in" = "Ingrandisci"; "chart_zoom_out" = "Riduci"; "chart_zoom_reset" = "Reimposta zoom"; + +"quick_update_paste_suggestion" = "Incolla %@ in %@"; +"quick_update_next_badge" = "PROSSIMA"; diff --git a/PortfolioJournal/Resources/ja.lproj/Localizable.strings b/PortfolioJournal/Resources/ja.lproj/Localizable.strings index 7939adc..825e1fe 100644 --- a/PortfolioJournal/Resources/ja.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/ja.lproj/Localizable.strings @@ -446,3 +446,6 @@ "chart_zoom_in" = "拡大"; "chart_zoom_out" = "縮小"; "chart_zoom_reset" = "ズームをリセット"; + +"quick_update_paste_suggestion" = "%@ を %@ に貼り付け"; +"quick_update_next_badge" = "次"; diff --git a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings index 0877fb7..ab06086 100644 --- a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings @@ -446,3 +446,6 @@ "chart_zoom_in" = "Ampliar"; "chart_zoom_out" = "Reduzir"; "chart_zoom_reset" = "Redefinir zoom"; + +"quick_update_paste_suggestion" = "Colar %@ em %@"; +"quick_update_next_badge" = "PRÓXIMA"; diff --git a/PortfolioJournal/Utilities/SharedQuickUpdate.swift b/PortfolioJournal/Utilities/SharedQuickUpdate.swift new file mode 100644 index 0000000..4d8c58f --- /dev/null +++ b/PortfolioJournal/Utilities/SharedQuickUpdate.swift @@ -0,0 +1,142 @@ +import Foundation +import CoreData + +// MARK: - App Group bridge for the Quick Update share extension +// +// The share extension can't open the CloudKit-backed Core Data store, so the app +// maintains a lightweight mirror of active sources in the App Group defaults and +// the extension appends "pending updates" to a queue there. The app ingests the +// queue on every activation, creating real snapshots. + +/// Snapshot of an active source, enough for the extension's picker. +struct SharedSourceInfo: Codable, Identifiable { + let id: UUID + let name: String + let latestValue: Double + let currencyCode: String + /// True when a value was already recorded this month (by app or extension). + var updatedThisMonth: Bool +} + +/// A value captured from the share extension, waiting to become a Snapshot. +struct PendingQuickUpdate: Codable { + let sourceId: UUID + let amount: Double + let capturedAt: Date +} + +enum SharedQuickUpdateStore { + private static let mirrorKey = "sharedSourceMirror" + private static let queueKey = "pendingQuickUpdates" + + static var defaults: UserDefaults? { + UserDefaults(suiteName: AppConstants.appGroupIdentifier) + } + + // MARK: Mirror (app writes, extension reads) + + static func writeMirror(_ sources: [SharedSourceInfo]) { + guard let data = try? JSONEncoder().encode(sources) else { return } + defaults?.set(data, forKey: mirrorKey) + } + + static func readMirror() -> [SharedSourceInfo] { + guard let data = defaults?.data(forKey: mirrorKey), + let decoded = try? JSONDecoder().decode([SharedSourceInfo].self, from: data) else { return [] } + return decoded + } + + // MARK: Pending queue (extension writes, app drains) + + static func appendPending(_ update: PendingQuickUpdate) { + var queue = readPending() + queue.append(update) + if let data = try? JSONEncoder().encode(queue) { + defaults?.set(data, forKey: queueKey) + } + // Optimistically mark the source as updated in the mirror so the extension + // suggests the next pending source on the following invocation. + var mirror = readMirror() + if let idx = mirror.firstIndex(where: { $0.id == update.sourceId }) { + mirror[idx].updatedThisMonth = true + writeMirror(mirror) + } + } + + static func readPending() -> [PendingQuickUpdate] { + guard let data = defaults?.data(forKey: queueKey), + let decoded = try? JSONDecoder().decode([PendingQuickUpdate].self, from: data) else { return [] } + return decoded + } + + static func clearPending() { + defaults?.removeObject(forKey: queueKey) + } +} + +// MARK: - App-side sync & ingestion + +enum SharedQuickUpdateSync { + /// Refreshes the source mirror for the extension. Call on app activation and + /// after snapshot saves. + @MainActor + static func refreshMirror() { + let context = CoreDataStack.shared.viewContext + let request = InvestmentSource.fetchRequest() + request.predicate = NSPredicate(format: "isActive == YES") + request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + guard let sources = try? context.fetch(request) else { return } + + let settings = AppSettings.getOrCreate(in: context) + let calendar = Calendar.current + let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date() + + let mirror = sources.map { source in + SharedSourceInfo( + id: source.id, + name: source.name, + latestValue: NSDecimalNumber(decimal: source.latestValue).doubleValue, + currencyCode: source.account?.currency ?? settings.currency, + updatedThisMonth: (source.latestSnapshot?.date ?? .distantPast) >= monthStart + ) + } + SharedQuickUpdateStore.writeMirror(mirror) + } + + /// Drains the extension's pending queue into real snapshots. Returns how many + /// snapshots were created. Call on app activation. + @MainActor + @discardableResult + static func ingestPending() -> Int { + let pending = SharedQuickUpdateStore.readPending() + guard !pending.isEmpty else { return 0 } + + let context = CoreDataStack.shared.viewContext + let request = InvestmentSource.fetchRequest() + guard let sources = try? context.fetch(request) else { return 0 } + let byId = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) }) + + var created = 0 + for update in pending { + guard let source = byId[update.sourceId] else { continue } + let snapshot = Snapshot(context: context) + snapshot.id = UUID() + snapshot.value = NSDecimalNumber(value: update.amount) + snapshot.date = update.capturedAt + snapshot.source = source + created += 1 + } + + if context.hasChanges { + do { + try context.save() + } catch { + print("Failed to ingest pending quick updates: \(error)") + return 0 + } + } + SharedQuickUpdateStore.clearPending() + refreshMirror() + return created + } +} diff --git a/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift b/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift index e699363..894b1e1 100644 --- a/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift +++ b/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift @@ -1,9 +1,11 @@ import SwiftUI import CoreData +import UIKit struct QuickUpdateView: View { @Environment(\.dismiss) private var dismiss @Environment(\.managedObjectContext) private var context + @Environment(\.scenePhase) private var scenePhase @FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)], predicate: NSPredicate(format: "isActive == YES"), @@ -15,6 +17,19 @@ struct QuickUpdateView: View { @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? + + /// 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 { @@ -28,6 +43,29 @@ struct QuickUpdateView: View { ) } else { List { + 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)) + .foregroundColor(.white) + .padding(.vertical, 2) + } + .listRowBackground(Color.appPrimary) + } + } + Section { ForEach(sources) { source in sourceRow(source) @@ -63,7 +101,13 @@ struct QuickUpdateView: View { } message: { Text(saveError ?? "") } - .onAppear { prefillContributions() } + .onAppear { + prefillContributions() + checkClipboard() + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { checkClipboard() } + } } } @@ -72,8 +116,19 @@ struct QuickUpdateView: View { VStack(spacing: 6) { HStack { VStack(alignment: .leading, spacing: 2) { - Text(source.name) - .font(.subheadline.weight(.medium)) + 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)) + .foregroundColor(.appSecondary) + .clipShape(Capsule()) + } + } if source.latestValue != .zero { Text(source.latestValue.currencyString) .font(.caption) @@ -89,6 +144,7 @@ struct QuickUpdateView: View { .multilineTextAlignment(.trailing) .frame(width: 120) .font(.subheadline) + .focused($focusedSource, equals: source.objectID) } if contributions[source.objectID] != nil { @@ -129,6 +185,29 @@ struct QuickUpdateView: View { ) } + /// Reads the pasteboard and offers the parsed amount for the next pending source. + /// Skips values already suggested (or already typed) so returning to the app + /// with the same clipboard doesn't nag. + private func checkClipboard() { + guard let raw = UIPasteboard.general.string, raw != lastSuggestedRaw, + let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0, + nextEmptySource != nil else { + return + } + lastSuggestedRaw = raw + clipboardAmount = parsed + } + + 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) { @@ -165,6 +244,8 @@ struct QuickUpdateView: View { 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() dismiss() } catch { saveError = error.localizedDescription diff --git a/PortfolioJournalQuickUpdate/ExtSharedBridge.swift b/PortfolioJournalQuickUpdate/ExtSharedBridge.swift new file mode 100644 index 0000000..be280e7 --- /dev/null +++ b/PortfolioJournalQuickUpdate/ExtSharedBridge.swift @@ -0,0 +1,103 @@ +import Foundation + +// MARK: - App Group bridge (extension side) +// +// KEEP IN SYNC with PortfolioJournal/Utilities/SharedQuickUpdate.swift — same +// UserDefaults suite, same keys, same JSON shapes. Duplicated here so the +// extension stays self-contained (no Core Data, no app-target sources). + +struct ExtSourceInfo: Codable, Identifiable { + let id: UUID + let name: String + let latestValue: Double + let currencyCode: String + var updatedThisMonth: Bool +} + +struct ExtPendingUpdate: Codable { + let sourceId: UUID + let amount: Double + let capturedAt: Date +} + +enum ExtSharedStore { + static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal" + private static let mirrorKey = "sharedSourceMirror" + private static let queueKey = "pendingQuickUpdates" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: appGroupIdentifier) + } + + static func readMirror() -> [ExtSourceInfo] { + guard let data = defaults?.data(forKey: mirrorKey), + let decoded = try? JSONDecoder().decode([ExtSourceInfo].self, from: data) else { return [] } + return decoded + } + + static func appendPending(_ update: ExtPendingUpdate) { + var queue: [ExtPendingUpdate] = [] + if let data = defaults?.data(forKey: queueKey), + let decoded = try? JSONDecoder().decode([ExtPendingUpdate].self, from: data) { + queue = decoded + } + queue.append(update) + if let data = try? JSONEncoder().encode(queue) { + defaults?.set(data, forKey: queueKey) + } + // Mark the source as updated so the next share preselects the following one. + var mirror = readMirror() + if let idx = mirror.firstIndex(where: { $0.id == update.sourceId }) { + mirror[idx].updatedThisMonth = true + if let data = try? JSONEncoder().encode(mirror) { + defaults?.set(data, forKey: mirrorKey) + } + } + } +} + +// MARK: - Amount parsing (locale-tolerant) + +enum ExtAmountParser { + /// Parses strings like "12.345,67 €", "$1,234.56", "1234,5" into a Decimal. + static func parse(_ raw: String) -> Decimal? { + // Strip everything except digits and separators. + let cleaned = raw.filter { $0.isNumber || $0 == "." || $0 == "," } + guard !cleaned.isEmpty else { return nil } + + let lastDot = cleaned.lastIndex(of: ".") + let lastComma = cleaned.lastIndex(of: ",") + + var normalized = cleaned + switch (lastDot, lastComma) { + case let (dot?, comma?): + // Both present: the LAST one is the decimal separator. + if dot > comma { + normalized = cleaned.replacingOccurrences(of: ",", with: "") + } else { + normalized = cleaned.replacingOccurrences(of: ".", with: "") + .replacingOccurrences(of: ",", with: ".") + } + case (nil, let comma?): + // Only commas: decimal if the trailing group isn't exactly 3 digits. + let trailing = cleaned.distance(from: cleaned.index(after: comma), to: cleaned.endIndex) + if trailing == 3 && cleaned.filter({ $0 == "," }).count > 0 && cleaned.count > 4 { + normalized = cleaned.replacingOccurrences(of: ",", with: "") + } else { + normalized = cleaned.replacingOccurrences(of: ",", with: ".") + } + case (let dot?, nil): + // Only dots: decimal if the trailing group isn't exactly 3 digits. + let trailing = cleaned.distance(from: cleaned.index(after: dot), to: cleaned.endIndex) + if trailing == 3 && cleaned.filter({ $0 == "." }).count > 1 { + normalized = cleaned.replacingOccurrences(of: ".", with: "") + } + case (nil, nil): + break + } + + guard let value = Decimal(string: normalized, locale: Locale(identifier: "en_US_POSIX")), + value > 0 else { return nil } + return value + } +} diff --git a/PortfolioJournalQuickUpdate/Info.plist b/PortfolioJournalQuickUpdate/Info.plist new file mode 100644 index 0000000..45ac010 --- /dev/null +++ b/PortfolioJournalQuickUpdate/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDisplayName + Portfolio Journal + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/PortfolioJournalQuickUpdate/ShareViewController.swift b/PortfolioJournalQuickUpdate/ShareViewController.swift new file mode 100644 index 0000000..2258dfd --- /dev/null +++ b/PortfolioJournalQuickUpdate/ShareViewController.swift @@ -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 } + } +} diff --git a/PortfolioJournalQuickUpdate/de.lproj/Localizable.strings b/PortfolioJournalQuickUpdate/de.lproj/Localizable.strings new file mode 100644 index 0000000..ec4c4b4 --- /dev/null +++ b/PortfolioJournalQuickUpdate/de.lproj/Localizable.strings @@ -0,0 +1,11 @@ +"ext_title" = "Schnell-Update"; +"ext_cancel" = "Abbrechen"; +"ext_save" = "Sichern"; +"ext_amount_section" = "Betrag"; +"ext_amount_placeholder" = "0,00"; +"ext_source_section" = "Quelle"; +"ext_already_updated" = "Diesen Monat aktualisiert"; +"ext_saved_title" = "Gesichert!"; +"ext_saved_body" = "Erscheint in Portfolio Journal beim nächsten Öffnen."; +"ext_no_sources_title" = "Noch keine Quellen"; +"ext_no_sources_body" = "Öffne Portfolio Journal einmal, damit deine Quellen hier erscheinen."; diff --git a/PortfolioJournalQuickUpdate/en.lproj/Localizable.strings b/PortfolioJournalQuickUpdate/en.lproj/Localizable.strings new file mode 100644 index 0000000..e964110 --- /dev/null +++ b/PortfolioJournalQuickUpdate/en.lproj/Localizable.strings @@ -0,0 +1,11 @@ +"ext_title" = "Quick Update"; +"ext_cancel" = "Cancel"; +"ext_save" = "Save"; +"ext_amount_section" = "Amount"; +"ext_amount_placeholder" = "0.00"; +"ext_source_section" = "Source"; +"ext_already_updated" = "Updated this month"; +"ext_saved_title" = "Saved!"; +"ext_saved_body" = "It will appear in Portfolio Journal the next time you open it."; +"ext_no_sources_title" = "No sources yet"; +"ext_no_sources_body" = "Open Portfolio Journal once so your sources appear here."; diff --git a/PortfolioJournalQuickUpdate/es-ES.lproj/Localizable.strings b/PortfolioJournalQuickUpdate/es-ES.lproj/Localizable.strings new file mode 100644 index 0000000..f546b47 --- /dev/null +++ b/PortfolioJournalQuickUpdate/es-ES.lproj/Localizable.strings @@ -0,0 +1,11 @@ +"ext_title" = "Actualización rápida"; +"ext_cancel" = "Cancelar"; +"ext_save" = "Guardar"; +"ext_amount_section" = "Importe"; +"ext_amount_placeholder" = "0,00"; +"ext_source_section" = "Fuente"; +"ext_already_updated" = "Actualizada este mes"; +"ext_saved_title" = "¡Guardado!"; +"ext_saved_body" = "Aparecerá en Portfolio Journal la próxima vez que la abras."; +"ext_no_sources_title" = "Aún no hay fuentes"; +"ext_no_sources_body" = "Abre Portfolio Journal una vez para que tus fuentes aparezcan aquí."; diff --git a/PortfolioJournalQuickUpdate/fr.lproj/Localizable.strings b/PortfolioJournalQuickUpdate/fr.lproj/Localizable.strings new file mode 100644 index 0000000..04270d9 --- /dev/null +++ b/PortfolioJournalQuickUpdate/fr.lproj/Localizable.strings @@ -0,0 +1,11 @@ +"ext_title" = "Mise à jour rapide"; +"ext_cancel" = "Annuler"; +"ext_save" = "Enregistrer"; +"ext_amount_section" = "Montant"; +"ext_amount_placeholder" = "0,00"; +"ext_source_section" = "Source"; +"ext_already_updated" = "Déjà à jour ce mois-ci"; +"ext_saved_title" = "Enregistré !"; +"ext_saved_body" = "Apparaîtra dans Portfolio Journal à la prochaine ouverture."; +"ext_no_sources_title" = "Aucune source"; +"ext_no_sources_body" = "Ouvrez Portfolio Journal une fois pour que vos sources apparaissent ici."; diff --git a/PortfolioJournalQuickUpdate/it.lproj/Localizable.strings b/PortfolioJournalQuickUpdate/it.lproj/Localizable.strings new file mode 100644 index 0000000..b5c0953 --- /dev/null +++ b/PortfolioJournalQuickUpdate/it.lproj/Localizable.strings @@ -0,0 +1,11 @@ +"ext_title" = "Aggiornamento rapido"; +"ext_cancel" = "Annulla"; +"ext_save" = "Salva"; +"ext_amount_section" = "Importo"; +"ext_amount_placeholder" = "0,00"; +"ext_source_section" = "Fonte"; +"ext_already_updated" = "Aggiornata questo mese"; +"ext_saved_title" = "Salvato!"; +"ext_saved_body" = "Apparirà in Portfolio Journal alla prossima apertura."; +"ext_no_sources_title" = "Nessuna fonte"; +"ext_no_sources_body" = "Apri Portfolio Journal una volta perché le tue fonti appaiano qui."; diff --git a/PortfolioJournalQuickUpdate/ja.lproj/Localizable.strings b/PortfolioJournalQuickUpdate/ja.lproj/Localizable.strings new file mode 100644 index 0000000..dd89ec1 --- /dev/null +++ b/PortfolioJournalQuickUpdate/ja.lproj/Localizable.strings @@ -0,0 +1,11 @@ +"ext_title" = "クイック更新"; +"ext_cancel" = "キャンセル"; +"ext_save" = "保存"; +"ext_amount_section" = "金額"; +"ext_amount_placeholder" = "0"; +"ext_source_section" = "投資先"; +"ext_already_updated" = "今月更新済み"; +"ext_saved_title" = "保存しました"; +"ext_saved_body" = "次回Portfolio Journalを開いたときに反映されます。"; +"ext_no_sources_title" = "投資先がありません"; +"ext_no_sources_body" = "一度Portfolio Journalを開くと、ここに投資先が表示されます。"; diff --git a/PortfolioJournalQuickUpdate/pt-BR.lproj/Localizable.strings b/PortfolioJournalQuickUpdate/pt-BR.lproj/Localizable.strings new file mode 100644 index 0000000..5a318d6 --- /dev/null +++ b/PortfolioJournalQuickUpdate/pt-BR.lproj/Localizable.strings @@ -0,0 +1,11 @@ +"ext_title" = "Atualização rápida"; +"ext_cancel" = "Cancelar"; +"ext_save" = "Salvar"; +"ext_amount_section" = "Valor"; +"ext_amount_placeholder" = "0,00"; +"ext_source_section" = "Fonte"; +"ext_already_updated" = "Atualizada este mês"; +"ext_saved_title" = "Salvo!"; +"ext_saved_body" = "Aparecerá no Portfolio Journal na próxima vez que você abrir."; +"ext_no_sources_title" = "Nenhuma fonte"; +"ext_no_sources_body" = "Abra o Portfolio Journal uma vez para que suas fontes apareçam aqui."; diff --git a/PortfolioJournalQuickUpdateExtension.entitlements b/PortfolioJournalQuickUpdateExtension.entitlements new file mode 100644 index 0000000..c2a86f9 --- /dev/null +++ b/PortfolioJournalQuickUpdateExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.alexandrevazquez.portfoliojournal + + + diff --git a/Scripts/archive_and_upload_appstore.sh b/Scripts/archive_and_upload_appstore.sh index f14be78..9c0fa74 100755 --- a/Scripts/archive_and_upload_appstore.sh +++ b/Scripts/archive_and_upload_appstore.sh @@ -106,12 +106,18 @@ EOF archive_app() { echo "==> Archivando ${SCHEME}" + # -allowProvisioningUpdates con API key: permite registrar bundle ids nuevos + # (p.ej. extensiones recién añadidas) y crear sus perfiles durante el archive. xcodebuild \ -project "$PROJECT_PATH" \ -scheme "$SCHEME" \ -configuration "$CONFIGURATION" \ -archivePath "$ARCHIVE_PATH" \ -destination "generic/platform=iOS" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$P8_PATH" \ + -authenticationKeyID "$KEY_ID" \ + -authenticationKeyIssuerID "$ISSUER_ID" \ clean archive }