#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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user