Files
InvestmentTrackerApp/PortfolioJournal/Services/AppIntentsSupport.swift
T
2026-08-06 20:11:58 +02:00

339 lines
14 KiB
Swift

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] {
SharedQuickUpdateStore.readMirror()
.filter { identifiers.contains($0.id) }
.map(SourceEntity.init)
}
func suggestedEntities() async throws -> [SourceEntity] {
// Pending-first, mirroring the Share Extension's ordering.
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<NSManagedObject>(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<NSManagedObject>(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<DateComponents>()
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..<max(sortedMonthsDesc.count, 1) {
guard i < sortedMonthsDesc.count,
let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
let prev = sortedMonthsDesc[i]
let pc = calendar.dateComponents([.year, .month], from: prev)
let ec = calendar.dateComponents([.year, .month], from: expected)
if pc.year == ec.year && pc.month == ec.month {
streak += 1
current = prev
} else { break }
}
}
// Last check-in from the most recent completed JournalEntry.
let journalRequest = NSFetchRequest<NSManagedObject>(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"
)
}
}