import AppIntents import Foundation import CoreData // MARK: - App Intents (Siri / Shortcuts / Spotlight) // // Surfaces the core monthly habit outside the app: "Update my portfolio" opens // Quick Update directly, "Check my portfolio" opens the dashboard. Registering an // AppShortcutsProvider also lists these actions in Spotlight and the Shortcuts app // with zero user setup — a discovery/retention surface the app didn't have. struct QuickUpdateIntent: AppIntent { static let title: LocalizedStringResource = "Update Portfolio" static let description = IntentDescription("Open Quick Update to record your latest portfolio values.") static let openAppWhenRun = true @MainActor func perform() async throws -> some IntentResult { // Same signal the widget deep link uses; ContentView routes it to the sheet. NotificationCenter.default.post(name: .openQuickUpdate, object: nil) return .result() } } struct OpenDashboardIntent: AppIntent { static let title: LocalizedStringResource = "Check Portfolio" static let description = IntentDescription("Open the dashboard to see your net worth and charts.") static let openAppWhenRun = true @MainActor func perform() async throws -> some IntentResult { NotificationCenter.default.post(name: .openDashboard, object: nil) return .result() } } // MARK: - Parametric logging (headless) /// An investment source exposed to Siri/Shortcuts, backed by the App Group /// mirror the Share Extension already uses — no Core Data access needed at /// resolution time, so it works instantly even before the app launches. struct SourceEntity: AppEntity { static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Source") static let defaultQuery = SourceEntityQuery() let id: UUID let name: String let currencyCode: String var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(name)") } init(_ info: SharedSourceInfo) { id = info.id name = info.name currencyCode = info.currencyCode } } struct SourceEntityQuery: EntityQuery { func entities(for identifiers: [UUID]) async throws -> [SourceEntity] { await MainActor.run { SharedQuickUpdateStore.readMirror() } .filter { identifiers.contains($0.id) } .map(SourceEntity.init) } func suggestedEntities() async throws -> [SourceEntity] { // Pending-first, mirroring the Share Extension's ordering. await MainActor.run { SharedQuickUpdateStore.readMirror() } .sorted { a, b in if a.updatedThisMonth != b.updatedThisMonth { return !a.updatedThisMonth } return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending } .map(SourceEntity.init) } } /// "Log 15000 for Indexa" — records a snapshot without opening any UI. Runs in /// the app process in the background: the value goes through the same pending /// queue as the Share Extension and is ingested into Core Data immediately. struct LogSnapshotIntent: AppIntent { static let title: LocalizedStringResource = "Log Portfolio Value" static let description = IntentDescription("Record a value for one of your sources without opening the app.") static let openAppWhenRun = false @Parameter(title: "Source") var source: SourceEntity @Parameter(title: "Amount", requestValueDialog: "How much?") var amount: Double static var parameterSummary: some ParameterSummary { Summary("Log \(\.$amount) for \(\.$source)") } @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { guard amount > 0 else { throw $amount.needsValueError(IntentDialog(stringLiteral: String(localized: "intent_amount_invalid"))) } SharedQuickUpdateStore.appendPending(PendingQuickUpdate( sourceId: source.id, amount: amount, capturedAt: Date() )) // We're inside the app process: materialize the snapshot right away. SharedQuickUpdateSync.ingestPending() let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = source.currencyCode let formatted = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)" let text = String(format: String(localized: "intent_logged_dialog"), formatted, source.name) return .result(dialog: IntentDialog(stringLiteral: text)) } } // MARK: - Net worth / streak (read-only, from the shared App Group store) /// Reads aggregate portfolio facts from the App Group Core Data store the same /// way the widget does: a read-only container over the shared SQLite file. This /// lets the intents answer instantly without touching the app's CloudKit stack. enum SharedPortfolioReader { private static let storeFileName = "PortfolioJournal.sqlite" struct Snapshot { let totalValue: Decimal let currencyCode: String let streak: Int let lastCheckIn: Date? } private static func sharedStoreURL() -> URL? { FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupIdentifier)? .appendingPathComponent(storeFileName) } private static func makeContainer() -> NSPersistentContainer? { guard let modelURL = Bundle.main.url(forResource: "PortfolioJournal", withExtension: "momd"), let model = NSManagedObjectModel(contentsOf: modelURL), let storeURL = sharedStoreURL(), FileManager.default.fileExists(atPath: storeURL.path) else { return nil } let container = NSPersistentContainer(name: "PortfolioJournal", managedObjectModel: model) let description = NSPersistentStoreDescription(url: storeURL) description.isReadOnly = true description.shouldMigrateStoreAutomatically = true description.shouldInferMappingModelAutomatically = true container.persistentStoreDescriptions = [description] var loadError: Error? container.loadPersistentStores { _, error in loadError = error } return loadError == nil ? container : nil } static func read() -> Snapshot { guard let container = makeContainer() else { return Snapshot(totalValue: 0, currencyCode: "EUR", streak: 0, lastCheckIn: nil) } let context = container.viewContext let calendar = Calendar.current // Currency var currency = "EUR" let settingsRequest = NSFetchRequest(entityName: "AppSettings") settingsRequest.fetchLimit = 1 if let settings = try? context.fetch(settingsRequest).first, let code = settings.value(forKey: "currency") as? String, !code.isEmpty { currency = code } // Latest snapshot value per source → total net worth. // Also collect the set of (year, month) that have snapshot data for streak. let snapRequest = NSFetchRequest(entityName: "Snapshot") snapRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: true)] let snapshots = (try? context.fetch(snapRequest)) ?? [] var latestBySource: [NSManagedObjectID: (date: Date, value: Decimal)] = [:] var monthComponents = Set() for snap in snapshots { guard let source = snap.value(forKey: "source") as? NSManagedObject else { continue } let date = (snap.value(forKey: "date") as? Date) ?? .distantPast let value = (snap.value(forKey: "value") as? NSDecimalNumber)?.decimalValue ?? .zero let key = source.objectID if let existing = latestBySource[key] { if date >= existing.date { latestBySource[key] = (date, value) } } else { latestBySource[key] = (date, value) } let c = calendar.dateComponents([.year, .month], from: date) monthComponents.insert(DateComponents(year: c.year, month: c.month)) } let totalValue = latestBySource.values.reduce(Decimal.zero) { $0 + $1.value } // Streak: consecutive months with data counting back from most recent. let sortedMonthsDesc = monthComponents .compactMap { calendar.date(from: $0) } .sorted(by: >) var streak = 0 if let mostRecent = sortedMonthsDesc.first { streak = 1 var current = mostRecent for i in 1..(entityName: "JournalEntry") journalRequest.predicate = NSPredicate(format: "completionTime != nil") journalRequest.sortDescriptors = [NSSortDescriptor(key: "completionTime", ascending: false)] journalRequest.fetchLimit = 1 let lastCheckIn = (try? context.fetch(journalRequest))?.first? .value(forKey: "completionTime") as? Date return Snapshot(totalValue: totalValue, currencyCode: currency, streak: streak, lastCheckIn: lastCheckIn) } } /// "What's my net worth in Portfolio Journal" — reads the total portfolio value /// from the shared store and speaks it back. Runs headless (no app launch). struct NetWorthIntent: AppIntent { static let title: LocalizedStringResource = "Check Net Worth" // ITMS-90626: intent descriptions must not contain the word "Siri". static let description = IntentDescription("Get your total portfolio value.") static let openAppWhenRun = false @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { let snapshot = SharedPortfolioReader.read() let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = snapshot.currencyCode let formatted = formatter.string(from: NSDecimalNumber(decimal: snapshot.totalValue)) ?? "\(snapshot.totalValue)" let text: String if snapshot.totalValue == 0 { text = String(localized: "intent_networth_empty") } else { text = String(format: String(localized: "intent_networth_dialog"), formatted) } return .result(dialog: IntentDialog(stringLiteral: text)) } } /// "What's my streak in Portfolio Journal" — reports the current check-in streak /// and when you last logged. Nice-to-have engagement surface. Runs headless. struct StreakIntent: AppIntent { static let title: LocalizedStringResource = "Check Streak" // ITMS-90626: intent descriptions must not contain the word "Siri". static let description = IntentDescription("Get your current check-in streak.") static let openAppWhenRun = false @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { let snapshot = SharedPortfolioReader.read() let text: String if snapshot.streak >= 1 { if let last = snapshot.lastCheckIn { let df = DateFormatter() df.dateStyle = .medium df.timeStyle = .none text = String(format: String(localized: "intent_streak_with_date"), snapshot.streak, df.string(from: last)) } else { text = String(format: String(localized: "intent_streak"), snapshot.streak) } } else { text = String(localized: "intent_streak_none") } return .result(dialog: IntentDialog(stringLiteral: text)) } } struct PortfolioJournalShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( intent: QuickUpdateIntent(), phrases: [ "Update my portfolio in \(.applicationName)", "Add a snapshot in \(.applicationName)" ], shortTitle: "Update Portfolio", systemImageName: "plus.circle.fill" ) AppShortcut( intent: OpenDashboardIntent(), phrases: [ "Check my portfolio in \(.applicationName)", "Show my net worth in \(.applicationName)" ], shortTitle: "Check Portfolio", systemImageName: "chart.line.uptrend.xyaxis" ) AppShortcut( intent: LogSnapshotIntent(), phrases: [ "Log a value in \(.applicationName)", "Log my \(\.$source) balance in \(.applicationName)", "Update \(\.$source) in \(.applicationName)" ], shortTitle: "Log Value", systemImageName: "square.and.pencil" ) AppShortcut( intent: NetWorthIntent(), phrases: [ "What's my net worth in \(.applicationName)", "How much is my portfolio worth in \(.applicationName)", "Get my net worth from \(.applicationName)" ], shortTitle: "Net Worth", systemImageName: "eurosign.circle.fill" ) AppShortcut( intent: StreakIntent(), phrases: [ "What's my streak in \(.applicationName)", "Check my check-in streak in \(.applicationName)" ], shortTitle: "Streak", systemImageName: "flame.fill" ) } }