#10 Ocultar saldos: BalancePrivacyManager (balancesHidden + requireBiometricToReveal), modifier .hiddenBalance() (blur + máscara de bullets, ancho estable), botón ojo en Dashboard, revelar con Face ID vía AppLockService, sección Privacidad en Settings. Aplicado a total portfolio, forecast, sources, goals, monthly summary. #11 Siri/App Intents: NetWorthIntent ('¿cuál es mi patrimonio?' → 'Tu patrimonio es X'), StreakIntent, 5 AppShortcuts con frases. Widget interactivo iOS 17: botón Refresh (Button(intent:) → reloadAllTimelines, in-process) en medium/large, gated #available(iOS 17). Reader read-only del AppGroup. Dialogs Siri refactorizados a String(localized:). Localización: strings de #10 y de Siri en los 7 idiomas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
// MARK: - App Intents (Siri / Shortcuts / Spotlight)
|
||||
//
|
||||
@@ -117,6 +118,171 @@ struct LogSnapshotIntent: AppIntent {
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
static let description = IntentDescription("Ask Siri for 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"
|
||||
static let description = IntentDescription("Ask Siri about 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(
|
||||
@@ -147,5 +313,24 @@ struct PortfolioJournalShortcuts: AppShortcutsProvider {
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user