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
@@ -0,0 +1,78 @@
import SwiftUI
/// A view modifier that redacts monetary amounts when the "Hide balances"
/// privacy feature is active.
///
/// When `hidden` is true the underlying content is blurred and its value masked
/// with a bullet string, while keeping the original layout footprint so the UI
/// doesn't jump. When false, the real content shows through untouched.
///
/// Usage:
/// ```swift
/// Text(viewModel.formattedTotalValue)
/// .hiddenBalance() // observes BalancePrivacyManager.shared
/// // or explicitly:
/// Text(amount).hiddenBalance(isHidden)
/// ```
struct HiddenBalanceModifier: ViewModifier {
let hidden: Bool
func body(content: Content) -> some View {
content
.opacity(hidden ? 0 : 1)
.overlay {
if hidden {
// Bullet mask sized to the underlying content so layout width
// stays roughly stable and the amount is fully obscured.
GeometryReader { proxy in
Text("••••••")
.redacted(reason: .placeholder)
.blur(radius: 6)
.frame(width: proxy.size.width, height: proxy.size.height)
.allowsHitTesting(false)
}
}
}
.accessibilityLabel(hidden ? Text("Balance hidden") : Text(""))
}
}
/// A self-observing variant of `HiddenBalanceModifier` that subscribes to the
/// shared `BalancePrivacyManager` so any monetary label updates live when the
/// user toggles visibility without threading the flag through view params.
struct ObservingHiddenBalanceModifier: ViewModifier {
@ObservedObject private var privacy = BalancePrivacyManager.shared
func body(content: Content) -> some View {
content.modifier(HiddenBalanceModifier(hidden: privacy.balancesHidden))
}
}
extension View {
/// Redact this monetary label with an explicit hidden flag. Use inside views
/// that already observe `BalancePrivacyManager`.
func hiddenBalance(_ hidden: Bool) -> some View {
modifier(HiddenBalanceModifier(hidden: hidden))
}
/// Redact this monetary label, self-observing the shared privacy manager.
/// Use anywhere without threading the flag through view parameters.
func hiddenBalance() -> some View {
modifier(ObservingHiddenBalanceModifier())
}
}
/// A tap-to-reveal wrapper for the quick eye toggle. Renders an eye / eye.slash
/// button bound to the shared `BalancePrivacyManager`.
struct BalanceVisibilityToggle: View {
@ObservedObject var privacy = BalancePrivacyManager.shared
var body: some View {
Button {
privacy.toggleHidden()
} label: {
Image(systemName: privacy.balancesHidden ? "eye.slash" : "eye")
}
.accessibilityLabel(privacy.balancesHidden ? Text("Show balances") : Text("Hide balances"))
}
}
@@ -7,6 +7,7 @@ struct DashboardView: View {
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var accountStore: AccountStore
@EnvironmentObject var tabSelection: TabSelectionStore
@EnvironmentObject var balancePrivacy: BalancePrivacyManager
@StateObject private var viewModel: DashboardViewModel
@StateObject private var goalsViewModel = GoalsViewModel()
@State private var showingImport = false
@@ -84,6 +85,14 @@ struct DashboardView: View {
}
.popoverTip(QuickUpdateTip())
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
balancePrivacy.toggleHidden()
} label: {
Image(systemName: balancePrivacy.balancesHidden ? "eye.slash" : "eye")
}
.accessibilityLabel(balancePrivacy.balancesHidden ? Text("Show balances") : Text("Hide balances"))
}
ToolbarItem(placement: .navigationBarTrailing) {
accountFilterMenu
}
@@ -287,7 +296,7 @@ struct DashboardView: View {
switch section {
case .totalValue:
if config.isCollapsed {
CompactCard(title: "Portfolio", subtitle: viewModel.portfolioSummary.formattedTotalValue)
CompactCard(title: "Portfolio", subtitle: viewModel.portfolioSummary.formattedTotalValue, subtitleIsBalance: true)
} else {
TotalValueCard(
totalValue: viewModel.portfolioSummary.formattedTotalValue,
@@ -312,7 +321,8 @@ struct DashboardView: View {
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
)
},
sparklineData: viewModel.evolutionData
sparklineData: viewModel.evolutionData,
balancesHidden: balancePrivacy.balancesHidden
)
}
case .monthlyCheckIn:
@@ -444,6 +454,7 @@ struct TotalValueCard: View {
var isSinceInceptionPositive: Bool = true
var onShareTap: (() -> Void)?
var sparklineData: [(date: Date, value: Decimal)] = []
var balancesHidden: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -466,6 +477,7 @@ struct TotalValueCard: View {
.foregroundColor(.primary)
.minimumScaleFactor(0.6)
.lineLimit(1)
.hiddenBalance(balancesHidden)
HStack(spacing: 8) {
HStack(spacing: 4) {
@@ -505,6 +517,7 @@ struct TotalValueCard: View {
value: forecast.formattedForecastValue,
positive: forecast.isPositiveGrowth
)
.hiddenBalance(balancesHidden)
} else {
Button {
onUnlockTap?()
@@ -1164,6 +1177,7 @@ struct MonthlySummaryCard: View {
.foregroundColor(.secondary)
Text(summary.formattedContributions)
.font(.subheadline.weight(.semibold))
.hiddenBalance()
}
Spacer()
@@ -1175,6 +1189,7 @@ struct MonthlySummaryCard: View {
Text("\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
.font(.subheadline.weight(.semibold))
.foregroundColor(summary.netPerformance >= 0 ? .positiveGreen : .negativeRed)
.hiddenBalance()
}
}
}
@@ -1245,14 +1260,22 @@ struct DashboardCustomizeView: View {
struct CompactCard: View {
let title: String
let subtitle: String
var subtitleIsBalance: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
if subtitleIsBalance {
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
.hiddenBalance()
} else {
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
@@ -276,9 +276,11 @@ struct GoalRowView: View {
HStack(spacing: 4) {
Text(totalValue.currencyString)
.font(.subheadline.weight(.semibold))
.hiddenBalance()
Text("of \(goal.targetDecimal.currencyString)")
.font(.subheadline)
.foregroundColor(.secondary)
.hiddenBalance()
}
if let targetDate = goal.targetDate {
@@ -18,6 +18,7 @@ struct SettingsView: View {
@ObservedObject private var cloudStack = CoreDataStack.shared
@ObservedObject private var updateService = AppUpdateService.shared
@ObservedObject private var balancePrivacy = BalancePrivacyManager.shared
@State private var showingPinSetup = false
@State private var showingPinChange = false
@@ -58,6 +59,9 @@ struct SettingsView: View {
backupsSection
}
// Privacy Section
privacySection
// Security Section
securitySection
@@ -677,6 +681,24 @@ struct SettingsView: View {
// MARK: - Security Section
private var privacySection: some View {
Section {
Toggle("Hide Balances", isOn: $balancePrivacy.balancesHidden)
Toggle("Require Face ID to Reveal", isOn: $balancePrivacy.requireBiometricToReveal)
.onChange(of: balancePrivacy.requireBiometricToReveal) { _, enabled in
if enabled && !AppLockService.canUseBiometrics() {
balancePrivacy.requireBiometricToReveal = false
showingBiometricAlert = true
}
}
} header: {
Text("Privacy")
} footer: {
Text("Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them.")
}
}
private var securitySection: some View {
Section {
Toggle("Require PIN", isOn: $pinEnabled)
@@ -138,6 +138,7 @@ struct SourceDetailView: View {
// Current value
Text(viewModel.formattedCurrentValue)
.font(.system(size: 36, weight: .bold, design: .rounded))
.hiddenBalance()
// Return
HStack(spacing: 4) {
@@ -517,11 +518,13 @@ struct SnapshotRowView: View {
VStack(alignment: .trailing, spacing: 2) {
Text(snapshot.formattedValue)
.font(.subheadline.weight(.medium))
.hiddenBalance()
if snapshot.contribution != nil && snapshot.decimalContribution > 0 {
Text("+ \(snapshot.decimalContribution.currencyString)")
.font(.caption)
.foregroundColor(.appPrimary)
.hiddenBalance()
}
}
@@ -213,6 +213,7 @@ struct SourceListView: View {
.foregroundColor(.secondary)
Text(viewModel.formattedTotalValue)
.font(.title2.weight(.bold))
.hiddenBalance()
}
Spacer()
@@ -520,6 +521,7 @@ struct SourceRowView: View {
VStack(alignment: .trailing, spacing: 4) {
Text(source.latestValue.compactCurrencyString)
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.hiddenBalance()
HStack(spacing: 2) {
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")