Biometría genérica (Face ID / Touch ID / Optic ID) y What's New 1.7.0

- AppLockService.biometryKind lee LAContext.biometryType; textos e icono del
  bloqueo y de la privacidad de saldos ya no asumen Face ID (Duo puede llevar
  Touch ID). Nuevas claves biometric_* en los 7 idiomas.
- Entrada 1.7.0 en el catálogo What's New (Duo, inspector, biometría) con
  strings en los 7 idiomas.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
This commit is contained in:
alexandrev-tibco
2026-09-16 11:44:50 +02:00
parent 680255f69d
commit 6774d76146
11 changed files with 310 additions and 76 deletions
@@ -22,6 +22,26 @@ struct WhatsNewRelease: Identifiable {
enum WhatsNewCatalog {
static var releases: [WhatsNewRelease] {
[
WhatsNewRelease(version: "1.7.0", features: [
WhatsNewFeature(
icon: "iphone.gen3.and.iphone.gen3",
color: .appPrimary,
title: String(localized: "whats_new_170_duo_title"),
description: String(localized: "whats_new_170_duo_body")
),
WhatsNewFeature(
icon: "rectangle.trailinghalf.inset.filled",
color: .appSecondary,
title: String(localized: "whats_new_170_inspector_title"),
description: String(localized: "whats_new_170_inspector_body")
),
WhatsNewFeature(
icon: "touchid",
color: .positiveGreen,
title: String(localized: "whats_new_170_biometrics_title"),
description: String(localized: "whats_new_170_biometrics_body")
)
]),
WhatsNewRelease(version: "1.5.0", features: [
WhatsNewFeature(
icon: "sparkles",
@@ -149,7 +169,7 @@ struct WhatsNewView: View {
.font(.title.weight(.bold))
Text(String(localized: "whats_new_subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.top, 32)
@@ -162,7 +182,7 @@ struct WhatsNewView: View {
if showsVersionHeaders {
Text(String(format: String(localized: "whats_new_version_header"), release.version))
.font(.caption.weight(.bold))
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.appPrimary.opacity(0.12))
@@ -172,14 +192,14 @@ struct WhatsNewView: View {
HStack(spacing: 16) {
Image(systemName: feature.icon)
.font(.title2)
.foregroundColor(feature.color)
.foregroundStyle(feature.color)
.frame(width: 40)
VStack(alignment: .leading, spacing: 2) {
Text(feature.title)
.font(.subheadline.weight(.semibold))
Text(feature.description)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
}
@@ -188,7 +208,7 @@ struct WhatsNewView: View {
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(16)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
.padding()
@@ -199,11 +219,11 @@ struct WhatsNewView: View {
} label: {
Text(String(localized: "whats_new_continue"))
.font(.headline)
.foregroundColor(.white)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.cornerRadius(14)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding()
}
@@ -9,6 +9,7 @@ struct AppLockView: View {
@State private var errorMessage: String?
@State private var didAttemptBiometrics = false
@FocusState private var pinFocused: Bool
private let biometry = AppLockService.biometryKind
var body: some View {
ZStack {
@@ -28,7 +29,7 @@ struct AppLockView: View {
Text("Unlock Portfolio Journal to view your data.")
.font(.subheadline)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 30)
@@ -36,13 +37,14 @@ struct AppLockView: View {
Button {
authenticateWithBiometrics()
} label: {
Label("Unlock with Face ID", systemImage: "faceid")
Label(String(format: String(localized: "biometric_unlock_with"), biometry.displayName),
systemImage: biometry.systemImage)
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
}
.padding(.horizontal, 24)
}
@@ -56,7 +58,7 @@ struct AppLockView: View {
.font(.title3.weight(.semibold))
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
.clipShape(RoundedRectangle(cornerRadius: 12))
.focused($pinFocused)
.onChange(of: pin) { _, newValue in
pin = String(newValue.filter(\.isNumber).prefix(4))
@@ -78,7 +80,7 @@ struct AppLockView: View {
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
Spacer()
@@ -101,7 +103,7 @@ struct AppLockView: View {
} else if pinEnabled {
pinFocused = true
} else {
errorMessage = "Face ID failed. Please try again."
errorMessage = String(format: String(localized: "biometric_failed"), biometry.displayName)
}
}
}
@@ -149,20 +149,20 @@ struct SettingsView: View {
} message: {
Text("Restart the app to apply iCloud sync changes.")
}
.alert("Face ID Unavailable", isPresented: $showingBiometricAlert) {
.alert(Text(String(format: String(localized: "biometric_unavailable_title"), biometryName)), isPresented: $showingBiometricAlert) {
Button("OK") {}
} message: {
Text("Face ID isn't available on this device.")
Text(String(format: String(localized: "biometric_unavailable_body"), biometryName))
}
.alert("PIN Required", isPresented: $showingPinRequiredAlert) {
Button("OK") {}
} message: {
Text("Enable a PIN before turning on Face ID.")
Text(String(format: String(localized: "biometric_pin_required"), biometryName))
}
.alert("Disable Face ID First", isPresented: $showingPinDisableAlert) {
.alert(Text(String(format: String(localized: "biometric_disable_first_title"), biometryName)), isPresented: $showingPinDisableAlert) {
Button("OK") {}
} message: {
Text("Turn off Face ID before disabling your PIN.")
Text(String(format: String(localized: "biometric_disable_first_body"), biometryName))
}
.sheet(isPresented: $showingPinSetup) {
PinSetupView(title: "Set PIN") { pin in
@@ -184,7 +184,7 @@ struct SettingsView: View {
}
}
.sheet(isPresented: $showingPinVerifyForFaceId) {
PinVerifyView(title: "Enter PIN to Disable Face ID") { success in
PinVerifyView(title: String(format: String(localized: "biometric_pin_to_disable"), biometryName)) { success in
if success {
faceIdEnabled = false
}
@@ -207,19 +207,19 @@ struct SettingsView: View {
Link(destination: GoalShareService.appStoreURL) {
HStack {
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "update_available_title"))
.font(.subheadline.weight(.semibold))
if let v = updateService.latestVersion {
Text(String(format: String(localized: "update_available_body"), v))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.font(.caption)
}
}
@@ -237,14 +237,14 @@ struct SettingsView: View {
.frame(width: 36, height: 36)
.padding(6)
.background(Color.appPrimary.opacity(0.08))
.cornerRadius(10)
.clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) {
Text(appDisplayName)
.font(.headline)
Text("Long-term portfolio tracker")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
@@ -280,14 +280,14 @@ struct SettingsView: View {
if viewModel.isFamilyShared {
Text("Family Sharing")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "checkmark.seal.fill")
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
}
} else {
Button {
@@ -300,23 +300,23 @@ struct SettingsView: View {
.frame(width: 44, height: 44)
Image(systemName: "crown.fill")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
VStack(alignment: .leading, spacing: 2) {
Text("Upgrade to Premium")
.font(.headline)
.foregroundColor(.primary)
.foregroundStyle(.primary)
Text("Unlock all features for \(iapService.formattedPrice)")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -345,7 +345,7 @@ struct SettingsView: View {
Text("Notifications")
Spacer()
Text(viewModel.notificationsEnabled ? "Enabled" : "Disabled")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
.onTapGesture {
@@ -396,7 +396,7 @@ struct SettingsView: View {
systemImage: "internaldrive"
)
.font(.subheadline)
.foregroundColor(.primary)
.foregroundStyle(.primary)
Spacer()
@@ -411,27 +411,27 @@ struct SettingsView: View {
if cloudStack.isSyncing {
Label("Syncing with iCloud...", systemImage: "arrow.triangle.2.circlepath.icloud")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
HStack(spacing: 12) {
if let date = cloudStack.lastImportDate {
Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.down")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
Label("No import yet", systemImage: "icloud.and.arrow.down")
.font(.caption)
.foregroundColor(.orange)
.foregroundStyle(.orange)
}
if let date = cloudStack.lastExportDate {
Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
Label("No export yet", systemImage: "icloud.and.arrow.up")
.font(.caption)
.foregroundColor(.orange)
.foregroundStyle(.orange)
}
}
}
@@ -449,12 +449,12 @@ struct SettingsView: View {
systemImage: critical ? "exclamationmark.icloud" : "arrow.triangle.2.circlepath.icloud"
)
.font(.caption.weight(.semibold))
.foregroundColor(critical ? .red : .secondary)
.foregroundStyle(critical ? .red : .secondary)
if critical, let hint = cloudStack.lastSyncErrorHint {
Text(hint)
.font(.caption)
.foregroundColor(.primary)
.foregroundStyle(.primary)
}
// Technical detail is opt-in behind a disclosure, so it
@@ -463,11 +463,11 @@ struct SettingsView: View {
if let hint = cloudStack.lastSyncErrorHint, !critical {
Text(hint)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Text(error)
.font(.caption2)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.font(.caption2)
@@ -484,7 +484,7 @@ struct SettingsView: View {
} label: {
Label(String(localized: "icloud_copy_diagnostics"), systemImage: "doc.on.doc")
.font(.caption)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
}
// Force upload use when data exists locally but hasn't reached iCloud
@@ -492,7 +492,7 @@ struct SettingsView: View {
if let result = forceUploadResult {
Label(result, systemImage: "checkmark.icloud")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
Button {
isForceUploading = true
@@ -514,7 +514,7 @@ struct SettingsView: View {
}
}
.font(.caption)
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.disabled(isForceUploading)
}
}
@@ -533,7 +533,7 @@ struct SettingsView: View {
if !viewModel.isPremium {
Text("Premium")
.font(.caption.weight(.semibold))
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
}
}
}
@@ -553,7 +553,7 @@ struct SettingsView: View {
Text("Last auto backup")
Spacer()
Text(last, style: .date)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.font(.caption)
}
@@ -580,7 +580,7 @@ struct SettingsView: View {
if !viewModel.canExport {
Image(systemName: "lock.fill")
.font(.caption)
.foregroundColor(.appWarning)
.foregroundStyle(Color.appWarning)
}
}
}
@@ -598,21 +598,21 @@ struct SettingsView: View {
Text("Total Sources")
Spacer()
Text("\(viewModel.totalSources)")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack {
Text("Total Snapshots")
Spacer()
Text("\(viewModel.totalSnapshots)")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
HStack {
Text("Storage Used")
Spacer()
Text(viewModel.storageUsedText)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
} header: {
Text("Data")
@@ -648,7 +648,7 @@ struct SettingsView: View {
if viewModel.backups.isEmpty {
Text("No backups yet.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.backups) { backup in
HStack {
@@ -657,7 +657,7 @@ struct SettingsView: View {
.font(.subheadline)
Text("\(backup.location.rawValue) · \(formatBytes(backup.size))")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Spacer()
Button("Restore") {
@@ -685,7 +685,7 @@ struct SettingsView: View {
Section {
Toggle("Hide Balances", isOn: $balancePrivacy.balancesHidden)
Toggle("Require Face ID to Reveal", isOn: $balancePrivacy.requireBiometricToReveal)
Toggle(String(format: String(localized: "biometric_require_to_reveal"), biometryName), isOn: $balancePrivacy.requireBiometricToReveal)
.onChange(of: balancePrivacy.requireBiometricToReveal) { _, enabled in
if enabled && !AppLockService.canUseBiometrics() {
balancePrivacy.requireBiometricToReveal = false
@@ -695,10 +695,13 @@ struct SettingsView: View {
} 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.")
Text(String(format: String(localized: "privacy_footer_biometric"), biometryName))
}
}
/// Face ID / Touch ID / Optic ID resolved from the hardware, never assumed.
private var biometryName: String { AppLockService.biometryName }
private var securitySection: some View {
Section {
Toggle("Require PIN", isOn: $pinEnabled)
@@ -717,7 +720,7 @@ struct SettingsView: View {
}
}
Toggle("Enable Face ID", isOn: $faceIdEnabled)
Toggle(String(format: String(localized: "biometric_enable"), biometryName), isOn: $faceIdEnabled)
.onChange(of: faceIdEnabled) { _, enabled in
if enabled {
guard AppLockService.canUseBiometrics() else {
@@ -748,7 +751,7 @@ struct SettingsView: View {
} header: {
Text("App Lock")
} footer: {
Text("Use Face ID or a 4-digit PIN to protect your data.")
Text(String(format: String(localized: "biometric_lock_footer"), biometryName))
}
}
@@ -802,7 +805,7 @@ struct SettingsView: View {
Label("Allocation Targets", systemImage: "target")
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
} header: {
@@ -825,7 +828,7 @@ struct SettingsView: View {
if !viewModel.isPremium {
Text("Premium")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -844,7 +847,7 @@ struct SettingsView: View {
Text("Version")
Spacer()
Text(viewModel.appVersion)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
Link(destination: URL(string: AppConstants.URLs.privacyPolicy)!) {
@@ -853,7 +856,7 @@ struct SettingsView: View {
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -863,7 +866,7 @@ struct SettingsView: View {
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -873,7 +876,7 @@ struct SettingsView: View {
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
@@ -884,7 +887,7 @@ struct SettingsView: View {
Text("Rate App")
Spacer()
Image(systemName: "star.fill")
.foregroundColor(.yellow)
.foregroundStyle(.yellow)
}
}
} header: {
@@ -992,7 +995,7 @@ struct ExportOptionsSheet: View {
Text(viewModel.exportStatus)
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8)
} header: {
@@ -1005,7 +1008,7 @@ struct ExportOptionsSheet: View {
} label: {
HStack {
Image(systemName: "tablecells")
.foregroundColor(.positiveGreen)
.foregroundStyle(Color.positiveGreen)
.frame(width: 30)
VStack(alignment: .leading) {
@@ -1013,7 +1016,7 @@ struct ExportOptionsSheet: View {
.font(.headline)
Text("Compatible with Excel, Google Sheets")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -1023,7 +1026,7 @@ struct ExportOptionsSheet: View {
} label: {
HStack {
Image(systemName: "doc.text")
.foregroundColor(.appPrimary)
.foregroundStyle(Color.appPrimary)
.frame(width: 30)
VStack(alignment: .leading) {
@@ -1031,7 +1034,7 @@ struct ExportOptionsSheet: View {
.font(.headline)
Text("Full data structure for backup")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}
@@ -1043,7 +1046,7 @@ struct ExportOptionsSheet: View {
.navigationTitle(viewModel.isExporting ? "Exporting" : "Export Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Cancel") {
dismiss()
}
@@ -1089,16 +1092,16 @@ struct PinSetupView: View {
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { savePin() }
.disabled(pin.count < 4 || confirmPin.count < 4)
}
@@ -1142,19 +1145,19 @@ struct PinVerifyView: View {
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.negativeRed)
.foregroundStyle(Color.negativeRed)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
onResult(false)
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .topBarTrailing) {
Button("Verify") { verifyPin() }
.disabled(pin.count < 4)
}