1.6.0 #10 ocultar saldos + Face ID · #11 widget interactivo + Siri

#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:
alexandrev-tibco
2026-07-25 17:52:09 +02:00
parent 648c2bd805
commit 326be6db04
20 changed files with 587 additions and 8 deletions
@@ -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"
)
}
}