d941ecf27b
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
339 lines
14 KiB
Swift
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"
|
|
)
|
|
}
|
|
}
|