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
@@ -684,3 +684,26 @@
"goal_scope_all_categories" = "Alle Kategorien"; "goal_scope_all_categories" = "Alle Kategorien";
"goal_scope_footer_all" = "Alle Quellen deines Portfolios zählen für dieses Ziel."; "goal_scope_footer_all" = "Alle Quellen deines Portfolios zählen für dieses Ziel.";
"goal_scope_footer_category" = "Nur Quellen dieser Kategorie zählen für dieses Ziel."; "goal_scope_footer_category" = "Nur Quellen dieser Kategorie zählen für dieses Ziel.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID oder Touch ID";
"biometric_unlock_with" = "Mit %@ entsperren";
"biometric_failed" = "%@ fehlgeschlagen. Bitte erneut versuchen.";
"biometric_enable" = "%@ aktivieren";
"biometric_unavailable_title" = "%@ nicht verfügbar";
"biometric_unavailable_body" = "%@ ist auf diesem Gerät nicht verfügbar.";
"biometric_pin_required" = "Richte zuerst eine PIN ein, bevor du %@ aktivierst.";
"biometric_disable_first_title" = "Zuerst %@ deaktivieren";
"biometric_disable_first_body" = "Deaktiviere %@, bevor du deine PIN deaktivierst.";
"biometric_pin_to_disable" = "PIN eingeben, um %@ zu deaktivieren";
"biometric_require_to_reveal" = "%@ zum Anzeigen erforderlich";
"biometric_lock_footer" = "Schütze deine Daten mit %@ oder einer 4-stelligen PIN.";
"privacy_footer_biometric" = "Blendet alle Beträge hinter einer Unschärfe aus. Ideal für die App in der Öffentlichkeit oder für Screenshots. Optional %@ zum Anzeigen erforderlich.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Bereit für iPhone Duo";
"whats_new_170_duo_body" = "Auf dem Innendisplay zeigen Quellen und Tagebuch Liste und Detail nebeneinander, Diagramme erhalten eine Seitenleiste und mehr Höhe, und deine Position bleibt beim Auf- und Zuklappen erhalten.";
"whats_new_170_inspector_title" = "Schnellupdate neben deinen Diagrammen";
"whats_new_170_inspector_body" = "Auf breiten Bildschirmen öffnet sich das monatliche Schnellupdate in einem Seitenbereich das Dashboard bleibt sichtbar, während du Werte eingibst.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID oder Optic ID";
"whats_new_170_biometrics_body" = "App-Sperre und Saldo-Privatsphäre nutzen jetzt die Biometrie deines Geräts.";
@@ -685,3 +685,26 @@
"goal_scope_all_categories" = "All categories"; "goal_scope_all_categories" = "All categories";
"goal_scope_footer_all" = "Every source in your portfolio counts towards this goal."; "goal_scope_footer_all" = "Every source in your portfolio counts towards this goal.";
"goal_scope_footer_category" = "Only sources in this category count towards this goal."; "goal_scope_footer_category" = "Only sources in this category count towards this goal.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID or Touch ID";
"biometric_unlock_with" = "Unlock with %@";
"biometric_failed" = "%@ failed. Please try again.";
"biometric_enable" = "Enable %@";
"biometric_unavailable_title" = "%@ Unavailable";
"biometric_unavailable_body" = "%@ isn't available on this device.";
"biometric_pin_required" = "Enable a PIN before turning on %@.";
"biometric_disable_first_title" = "Disable %@ First";
"biometric_disable_first_body" = "Turn off %@ before disabling your PIN.";
"biometric_pin_to_disable" = "Enter PIN to Disable %@";
"biometric_require_to_reveal" = "Require %@ to Reveal";
"biometric_lock_footer" = "Use %@ or a 4-digit PIN to protect your data.";
"privacy_footer_biometric" = "Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require %@ to reveal them.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Ready for iPhone Duo";
"whats_new_170_duo_body" = "On the inner screen, Sources and Journal show list and detail side by side, Charts get a sidebar and taller plots, and your place is kept when you open or close the phone.";
"whats_new_170_inspector_title" = "Quick Update beside your charts";
"whats_new_170_inspector_body" = "On wide screens the monthly Quick Update opens in a side panel, so the dashboard stays visible while you type values.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID or Optic ID";
"whats_new_170_biometrics_body" = "App Lock and balance privacy now use whichever biometrics your device has.";
@@ -636,3 +636,26 @@
"goal_scope_all_categories" = "Todas las categorías"; "goal_scope_all_categories" = "Todas las categorías";
"goal_scope_footer_all" = "Todas las fuentes de tu cartera cuentan para este objetivo."; "goal_scope_footer_all" = "Todas las fuentes de tu cartera cuentan para este objetivo.";
"goal_scope_footer_category" = "Solo las fuentes de esta categoría cuentan para este objetivo."; "goal_scope_footer_category" = "Solo las fuentes de esta categoría cuentan para este objetivo.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID o Touch ID";
"biometric_unlock_with" = "Desbloquear con %@";
"biometric_failed" = "%@ ha fallado. Inténtalo de nuevo.";
"biometric_enable" = "Activar %@";
"biometric_unavailable_title" = "%@ no disponible";
"biometric_unavailable_body" = "%@ no está disponible en este dispositivo.";
"biometric_pin_required" = "Activa un PIN antes de activar %@.";
"biometric_disable_first_title" = "Desactiva %@ primero";
"biometric_disable_first_body" = "Desactiva %@ antes de desactivar tu PIN.";
"biometric_pin_to_disable" = "Introduce el PIN para desactivar %@";
"biometric_require_to_reveal" = "Requerir %@ para mostrar";
"biometric_lock_footer" = "Usa %@ o un PIN de 4 dígitos para proteger tus datos.";
"privacy_footer_biometric" = "Oculta todas las cifras tras un difuminado. Ideal para usar la app en público o hacer capturas. Opcionalmente, requiere %@ para mostrarlas.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Preparada para iPhone Duo";
"whats_new_170_duo_body" = "En la pantalla interior, Fuentes y Diario muestran lista y detalle a la vez, Gráficos gana una barra lateral y más altura, y no pierdes el sitio al abrir o cerrar el teléfono.";
"whats_new_170_inspector_title" = "Actualización rápida junto a tus gráficos";
"whats_new_170_inspector_body" = "En pantallas anchas la actualización rápida mensual se abre en un panel lateral: el panel principal sigue visible mientras escribes valores.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID u Optic ID";
"whats_new_170_biometrics_body" = "El bloqueo de la app y la privacidad de saldos usan la biometría que tenga tu dispositivo.";
@@ -684,3 +684,26 @@
"goal_scope_all_categories" = "Toutes les catégories"; "goal_scope_all_categories" = "Toutes les catégories";
"goal_scope_footer_all" = "Toutes les sources de votre portefeuille comptent pour cet objectif."; "goal_scope_footer_all" = "Toutes les sources de votre portefeuille comptent pour cet objectif.";
"goal_scope_footer_category" = "Seules les sources de cette catégorie comptent pour cet objectif."; "goal_scope_footer_category" = "Seules les sources de cette catégorie comptent pour cet objectif.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID ou Touch ID";
"biometric_unlock_with" = "Déverrouiller avec %@";
"biometric_failed" = "Échec de %@. Veuillez réessayer.";
"biometric_enable" = "Activer %@";
"biometric_unavailable_title" = "%@ indisponible";
"biometric_unavailable_body" = "%@ n'est pas disponible sur cet appareil.";
"biometric_pin_required" = "Définissez un code PIN avant d'activer %@.";
"biometric_disable_first_title" = "Désactivez d'abord %@";
"biometric_disable_first_body" = "Désactivez %@ avant de désactiver votre code PIN.";
"biometric_pin_to_disable" = "Saisissez le code PIN pour désactiver %@";
"biometric_require_to_reveal" = "Exiger %@ pour afficher";
"biometric_lock_footer" = "Protégez vos données avec %@ ou un code PIN à 4 chiffres.";
"privacy_footer_biometric" = "Masque tous les montants derrière un flou. Idéal pour utiliser l'app en public ou faire des captures. Exigez %@ pour les afficher, en option.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Prête pour iPhone Duo";
"whats_new_170_duo_body" = "Sur l'écran intérieur, Sources et Journal affichent liste et détail côte à côte, Graphiques gagne une barre latérale et plus de hauteur, et vous gardez votre position en ouvrant ou fermant le téléphone.";
"whats_new_170_inspector_title" = "Mise à jour rapide à côté de vos graphiques";
"whats_new_170_inspector_body" = "Sur les grands écrans, la mise à jour rapide mensuelle s'ouvre dans un panneau latéral : le tableau de bord reste visible pendant la saisie.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
"whats_new_170_biometrics_body" = "Le verrouillage de l'app et la confidentialité des soldes utilisent la biométrie de votre appareil.";
@@ -684,3 +684,26 @@
"goal_scope_all_categories" = "Tutte le categorie"; "goal_scope_all_categories" = "Tutte le categorie";
"goal_scope_footer_all" = "Tutte le fonti del tuo portafoglio contano per questo obiettivo."; "goal_scope_footer_all" = "Tutte le fonti del tuo portafoglio contano per questo obiettivo.";
"goal_scope_footer_category" = "Solo le fonti di questa categoria contano per questo obiettivo."; "goal_scope_footer_category" = "Solo le fonti di questa categoria contano per questo obiettivo.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID o Touch ID";
"biometric_unlock_with" = "Sblocca con %@";
"biometric_failed" = "%@ non riuscito. Riprova.";
"biometric_enable" = "Attiva %@";
"biometric_unavailable_title" = "%@ non disponibile";
"biometric_unavailable_body" = "%@ non è disponibile su questo dispositivo.";
"biometric_pin_required" = "Imposta un PIN prima di attivare %@.";
"biometric_disable_first_title" = "Disattiva prima %@";
"biometric_disable_first_body" = "Disattiva %@ prima di disattivare il PIN.";
"biometric_pin_to_disable" = "Inserisci il PIN per disattivare %@";
"biometric_require_to_reveal" = "Richiedi %@ per mostrare";
"biometric_lock_footer" = "Proteggi i tuoi dati con %@ o un PIN a 4 cifre.";
"privacy_footer_biometric" = "Nasconde tutti gli importi dietro una sfocatura. Ideale per usare l'app in pubblico o fare screenshot. Facoltativamente richiedi %@ per mostrarli.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Pronta per iPhone Duo";
"whats_new_170_duo_body" = "Sullo schermo interno, Fonti e Diario mostrano elenco e dettaglio affiancati, Grafici ottiene una barra laterale e più altezza, e non perdi la posizione aprendo o chiudendo il telefono.";
"whats_new_170_inspector_title" = "Aggiornamento rapido accanto ai grafici";
"whats_new_170_inspector_body" = "Sugli schermi ampi l'aggiornamento rapido mensile si apre in un pannello laterale: la dashboard resta visibile mentre inserisci i valori.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID o Optic ID";
"whats_new_170_biometrics_body" = "Blocco app e privacy dei saldi usano la biometria disponibile sul tuo dispositivo.";
@@ -684,3 +684,26 @@
"goal_scope_all_categories" = "すべてのカテゴリ"; "goal_scope_all_categories" = "すべてのカテゴリ";
"goal_scope_footer_all" = "ポートフォリオのすべての投資先がこの目標に加算されます。"; "goal_scope_footer_all" = "ポートフォリオのすべての投資先がこの目標に加算されます。";
"goal_scope_footer_category" = "このカテゴリの投資先のみがこの目標に加算されます。"; "goal_scope_footer_category" = "このカテゴリの投資先のみがこの目標に加算されます。";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face IDまたはTouch ID";
"biometric_unlock_with" = "%@でロック解除";
"biometric_failed" = "%@に失敗しました。もう一度お試しください。";
"biometric_enable" = "%@を有効にする";
"biometric_unavailable_title" = "%@は利用できません";
"biometric_unavailable_body" = "このデバイスでは%@を利用できません。";
"biometric_pin_required" = "%@を有効にする前にPINを設定してください。";
"biometric_disable_first_title" = "先に%@を無効にしてください";
"biometric_disable_first_body" = "PINを無効にする前に%@を無効にしてください。";
"biometric_pin_to_disable" = "%@を無効にするにはPINを入力";
"biometric_require_to_reveal" = "表示に%@を要求";
"biometric_lock_footer" = "%@または4桁のPINでデータを保護します。";
"privacy_footer_biometric" = "すべての金額をぼかして隠します。人前での利用やスクリーンショットに最適。表示に%@を要求することもできます。";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "iPhone Duoに対応";
"whats_new_170_duo_body" = "内側の画面では、投資先とジャーナルがリストと詳細を並べて表示し、グラフにはサイドバーと高さが加わり、本体を開閉しても表示位置が保たれます。";
"whats_new_170_inspector_title" = "グラフの横でクイック更新";
"whats_new_170_inspector_body" = "広い画面では月次のクイック更新がサイドパネルで開き、値を入力している間もダッシュボードが見えたままです。";
"whats_new_170_biometrics_title" = "Face ID、Touch ID、Optic ID";
"whats_new_170_biometrics_body" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。";
@@ -684,3 +684,26 @@
"goal_scope_all_categories" = "Todas as categorias"; "goal_scope_all_categories" = "Todas as categorias";
"goal_scope_footer_all" = "Todas as fontes da sua carteira contam para esta meta."; "goal_scope_footer_all" = "Todas as fontes da sua carteira contam para esta meta.";
"goal_scope_footer_category" = "Apenas as fontes desta categoria contam para esta meta."; "goal_scope_footer_category" = "Apenas as fontes desta categoria contam para esta meta.";
// MARK: - Biometrics (1.7.0 — Face ID / Touch ID / Optic ID aware)
"biometric_generic_name" = "Face ID ou Touch ID";
"biometric_unlock_with" = "Desbloquear com %@";
"biometric_failed" = "%@ falhou. Tente novamente.";
"biometric_enable" = "Ativar %@";
"biometric_unavailable_title" = "%@ indisponível";
"biometric_unavailable_body" = "%@ não está disponível neste dispositivo.";
"biometric_pin_required" = "Defina um PIN antes de ativar o %@.";
"biometric_disable_first_title" = "Desative o %@ primeiro";
"biometric_disable_first_body" = "Desative o %@ antes de desativar seu PIN.";
"biometric_pin_to_disable" = "Digite o PIN para desativar o %@";
"biometric_require_to_reveal" = "Exigir %@ para mostrar";
"biometric_lock_footer" = "Use %@ ou um PIN de 4 dígitos para proteger seus dados.";
"privacy_footer_biometric" = "Oculta todos os valores atrás de um desfoque. Ótimo para usar o app em público ou tirar prints. Opcionalmente, exija %@ para revelá-los.";
// MARK: - What's New 1.7.0
"whats_new_170_duo_title" = "Pronto para iPhone Duo";
"whats_new_170_duo_body" = "Na tela interna, Fontes e Diário mostram lista e detalhe lado a lado, Gráficos ganha uma barra lateral e mais altura, e você não perde o lugar ao abrir ou fechar o aparelho.";
"whats_new_170_inspector_title" = "Atualização rápida ao lado dos gráficos";
"whats_new_170_inspector_body" = "Em telas largas a atualização rápida mensal abre em um painel lateral: o painel principal continua visível enquanto você digita valores.";
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
"whats_new_170_biometrics_body" = "O bloqueio do app e a privacidade de saldos usam a biometria disponível no seu aparelho.";
@@ -1,6 +1,36 @@
import Foundation import Foundation
import LocalAuthentication import LocalAuthentication
/// Biometric hardware available on this device. Never assume Face ID: iPad and
/// iPhone Duo can ship with Touch ID, Vision Pro with Optic ID.
enum BiometryKind {
case faceID
case touchID
case opticID
case none
/// Marketing name shown in UI ("Face ID", "Touch ID", "Optic ID"). Falls back
/// to a generic label when no biometry is available (e.g. not enrolled).
var displayName: String {
switch self {
case .faceID: return "Face ID"
case .touchID: return "Touch ID"
case .opticID: return "Optic ID"
case .none: return String(localized: "biometric_generic_name")
}
}
/// SF Symbol matching the hardware.
var systemImage: String {
switch self {
case .faceID: return "faceid"
case .touchID: return "touchid"
case .opticID: return "opticid"
case .none: return "lock.shield"
}
}
}
enum AppLockService { enum AppLockService {
static func canUseBiometrics() -> Bool { static func canUseBiometrics() -> Bool {
let context = LAContext() let context = LAContext()
@@ -8,6 +38,24 @@ enum AppLockService {
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
} }
/// The biometry type reported by the system. `biometryType` is only populated
/// after `canEvaluatePolicy` runs, and stays set even when not enrolled.
static var biometryKind: BiometryKind {
let context = LAContext()
var error: NSError?
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
switch context.biometryType {
case .faceID: return .faceID
case .touchID: return .touchID
case .opticID: return .opticID
case .none: return .none
@unknown default: return .none
}
}
/// Localized biometry name for user-facing text.
static var biometryName: String { biometryKind.displayName }
static func authenticate(reason: String, completion: @escaping (Bool) -> Void) { static func authenticate(reason: String, completion: @escaping (Bool) -> Void) {
let context = LAContext() let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in
@@ -22,6 +22,26 @@ struct WhatsNewRelease: Identifiable {
enum WhatsNewCatalog { enum WhatsNewCatalog {
static var releases: [WhatsNewRelease] { 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: [ WhatsNewRelease(version: "1.5.0", features: [
WhatsNewFeature( WhatsNewFeature(
icon: "sparkles", icon: "sparkles",
@@ -149,7 +169,7 @@ struct WhatsNewView: View {
.font(.title.weight(.bold)) .font(.title.weight(.bold))
Text(String(localized: "whats_new_subtitle")) Text(String(localized: "whats_new_subtitle"))
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
.padding(.top, 32) .padding(.top, 32)
@@ -162,7 +182,7 @@ struct WhatsNewView: View {
if showsVersionHeaders { if showsVersionHeaders {
Text(String(format: String(localized: "whats_new_version_header"), release.version)) Text(String(format: String(localized: "whats_new_version_header"), release.version))
.font(.caption.weight(.bold)) .font(.caption.weight(.bold))
.foregroundColor(.appPrimary) .foregroundStyle(Color.appPrimary)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(Color.appPrimary.opacity(0.12)) .background(Color.appPrimary.opacity(0.12))
@@ -172,14 +192,14 @@ struct WhatsNewView: View {
HStack(spacing: 16) { HStack(spacing: 16) {
Image(systemName: feature.icon) Image(systemName: feature.icon)
.font(.title2) .font(.title2)
.foregroundColor(feature.color) .foregroundStyle(feature.color)
.frame(width: 40) .frame(width: 40)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(feature.title) Text(feature.title)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
Text(feature.description) Text(feature.description)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
} }
@@ -188,7 +208,7 @@ struct WhatsNewView: View {
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground)) .background(Color(.secondarySystemGroupedBackground))
.cornerRadius(16) .clipShape(RoundedRectangle(cornerRadius: 16))
} }
} }
.padding() .padding()
@@ -199,11 +219,11 @@ struct WhatsNewView: View {
} label: { } label: {
Text(String(localized: "whats_new_continue")) Text(String(localized: "whats_new_continue"))
.font(.headline) .font(.headline)
.foregroundColor(.white) .foregroundStyle(.white)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() .padding()
.background(Color.appPrimary) .background(Color.appPrimary)
.cornerRadius(14) .clipShape(RoundedRectangle(cornerRadius: 14))
} }
.padding() .padding()
} }
@@ -9,6 +9,7 @@ struct AppLockView: View {
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var didAttemptBiometrics = false @State private var didAttemptBiometrics = false
@FocusState private var pinFocused: Bool @FocusState private var pinFocused: Bool
private let biometry = AppLockService.biometryKind
var body: some View { var body: some View {
ZStack { ZStack {
@@ -28,7 +29,7 @@ struct AppLockView: View {
Text("Unlock Portfolio Journal to view your data.") Text("Unlock Portfolio Journal to view your data.")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 30) .padding(.horizontal, 30)
@@ -36,13 +37,14 @@ struct AppLockView: View {
Button { Button {
authenticateWithBiometrics() authenticateWithBiometrics()
} label: { } label: {
Label("Unlock with Face ID", systemImage: "faceid") Label(String(format: String(localized: "biometric_unlock_with"), biometry.displayName),
systemImage: biometry.systemImage)
.font(.headline) .font(.headline)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() .padding()
.background(Color.appPrimary) .background(Color.appPrimary)
.foregroundColor(.white) .foregroundStyle(.white)
.cornerRadius(AppConstants.UI.cornerRadius) .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
@@ -56,7 +58,7 @@ struct AppLockView: View {
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
.padding() .padding()
.background(Color.gray.opacity(0.1)) .background(Color.gray.opacity(0.1))
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 12))
.focused($pinFocused) .focused($pinFocused)
.onChange(of: pin) { _, newValue in .onChange(of: pin) { _, newValue in
pin = String(newValue.filter(\.isNumber).prefix(4)) pin = String(newValue.filter(\.isNumber).prefix(4))
@@ -78,7 +80,7 @@ struct AppLockView: View {
if let errorMessage { if let errorMessage {
Text(errorMessage) Text(errorMessage)
.font(.caption) .font(.caption)
.foregroundColor(.negativeRed) .foregroundStyle(Color.negativeRed)
} }
Spacer() Spacer()
@@ -101,7 +103,7 @@ struct AppLockView: View {
} else if pinEnabled { } else if pinEnabled {
pinFocused = true pinFocused = true
} else { } 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: { } message: {
Text("Restart the app to apply iCloud sync changes.") 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") {} Button("OK") {}
} message: { } message: {
Text("Face ID isn't available on this device.") Text(String(format: String(localized: "biometric_unavailable_body"), biometryName))
} }
.alert("PIN Required", isPresented: $showingPinRequiredAlert) { .alert("PIN Required", isPresented: $showingPinRequiredAlert) {
Button("OK") {} Button("OK") {}
} message: { } 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") {} Button("OK") {}
} message: { } message: {
Text("Turn off Face ID before disabling your PIN.") Text(String(format: String(localized: "biometric_disable_first_body"), biometryName))
} }
.sheet(isPresented: $showingPinSetup) { .sheet(isPresented: $showingPinSetup) {
PinSetupView(title: "Set PIN") { pin in PinSetupView(title: "Set PIN") { pin in
@@ -184,7 +184,7 @@ struct SettingsView: View {
} }
} }
.sheet(isPresented: $showingPinVerifyForFaceId) { .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 { if success {
faceIdEnabled = false faceIdEnabled = false
} }
@@ -207,19 +207,19 @@ struct SettingsView: View {
Link(destination: GoalShareService.appStoreURL) { Link(destination: GoalShareService.appStoreURL) {
HStack { HStack {
Image(systemName: "arrow.down.circle.fill") Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.appPrimary) .foregroundStyle(Color.appPrimary)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "update_available_title")) Text(String(localized: "update_available_title"))
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
if let v = updateService.latestVersion { if let v = updateService.latestVersion {
Text(String(format: String(localized: "update_available_body"), v)) Text(String(format: String(localized: "update_available_body"), v))
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.foregroundColor(.secondary) .foregroundStyle(.secondary)
.font(.caption) .font(.caption)
} }
} }
@@ -237,14 +237,14 @@ struct SettingsView: View {
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
.padding(6) .padding(6)
.background(Color.appPrimary.opacity(0.08)) .background(Color.appPrimary.opacity(0.08))
.cornerRadius(10) .clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(appDisplayName) Text(appDisplayName)
.font(.headline) .font(.headline)
Text("Long-term portfolio tracker") Text("Long-term portfolio tracker")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
@@ -280,14 +280,14 @@ struct SettingsView: View {
if viewModel.isFamilyShared { if viewModel.isFamilyShared {
Text("Family Sharing") Text("Family Sharing")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
Spacer() Spacer()
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.foregroundColor(.positiveGreen) .foregroundStyle(Color.positiveGreen)
} }
} else { } else {
Button { Button {
@@ -300,23 +300,23 @@ struct SettingsView: View {
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
Image(systemName: "crown.fill") Image(systemName: "crown.fill")
.foregroundColor(.appPrimary) .foregroundStyle(Color.appPrimary)
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Upgrade to Premium") Text("Upgrade to Premium")
.font(.headline) .font(.headline)
.foregroundColor(.primary) .foregroundStyle(.primary)
Text("Unlock all features for \(iapService.formattedPrice)") Text("Unlock all features for \(iapService.formattedPrice)")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -345,7 +345,7 @@ struct SettingsView: View {
Text("Notifications") Text("Notifications")
Spacer() Spacer()
Text(viewModel.notificationsEnabled ? "Enabled" : "Disabled") Text(viewModel.notificationsEnabled ? "Enabled" : "Disabled")
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
@@ -396,7 +396,7 @@ struct SettingsView: View {
systemImage: "internaldrive" systemImage: "internaldrive"
) )
.font(.subheadline) .font(.subheadline)
.foregroundColor(.primary) .foregroundStyle(.primary)
Spacer() Spacer()
@@ -411,27 +411,27 @@ struct SettingsView: View {
if cloudStack.isSyncing { if cloudStack.isSyncing {
Label("Syncing with iCloud...", systemImage: "arrow.triangle.2.circlepath.icloud") Label("Syncing with iCloud...", systemImage: "arrow.triangle.2.circlepath.icloud")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} else { } else {
HStack(spacing: 12) { HStack(spacing: 12) {
if let date = cloudStack.lastImportDate { if let date = cloudStack.lastImportDate {
Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.down") Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.down")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} else { } else {
Label("No import yet", systemImage: "icloud.and.arrow.down") Label("No import yet", systemImage: "icloud.and.arrow.down")
.font(.caption) .font(.caption)
.foregroundColor(.orange) .foregroundStyle(.orange)
} }
if let date = cloudStack.lastExportDate { if let date = cloudStack.lastExportDate {
Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up") Label("\(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} else { } else {
Label("No export yet", systemImage: "icloud.and.arrow.up") Label("No export yet", systemImage: "icloud.and.arrow.up")
.font(.caption) .font(.caption)
.foregroundColor(.orange) .foregroundStyle(.orange)
} }
} }
} }
@@ -449,12 +449,12 @@ struct SettingsView: View {
systemImage: critical ? "exclamationmark.icloud" : "arrow.triangle.2.circlepath.icloud" systemImage: critical ? "exclamationmark.icloud" : "arrow.triangle.2.circlepath.icloud"
) )
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundColor(critical ? .red : .secondary) .foregroundStyle(critical ? .red : .secondary)
if critical, let hint = cloudStack.lastSyncErrorHint { if critical, let hint = cloudStack.lastSyncErrorHint {
Text(hint) Text(hint)
.font(.caption) .font(.caption)
.foregroundColor(.primary) .foregroundStyle(.primary)
} }
// Technical detail is opt-in behind a disclosure, so it // Technical detail is opt-in behind a disclosure, so it
@@ -463,11 +463,11 @@ struct SettingsView: View {
if let hint = cloudStack.lastSyncErrorHint, !critical { if let hint = cloudStack.lastSyncErrorHint, !critical {
Text(hint) Text(hint)
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
Text(error) Text(error)
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
.textSelection(.enabled) .textSelection(.enabled)
} }
.font(.caption2) .font(.caption2)
@@ -484,7 +484,7 @@ struct SettingsView: View {
} label: { } label: {
Label(String(localized: "icloud_copy_diagnostics"), systemImage: "doc.on.doc") Label(String(localized: "icloud_copy_diagnostics"), systemImage: "doc.on.doc")
.font(.caption) .font(.caption)
.foregroundColor(.appPrimary) .foregroundStyle(Color.appPrimary)
} }
// Force upload use when data exists locally but hasn't reached iCloud // Force upload use when data exists locally but hasn't reached iCloud
@@ -492,7 +492,7 @@ struct SettingsView: View {
if let result = forceUploadResult { if let result = forceUploadResult {
Label(result, systemImage: "checkmark.icloud") Label(result, systemImage: "checkmark.icloud")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} else { } else {
Button { Button {
isForceUploading = true isForceUploading = true
@@ -514,7 +514,7 @@ struct SettingsView: View {
} }
} }
.font(.caption) .font(.caption)
.foregroundColor(.appPrimary) .foregroundStyle(Color.appPrimary)
.disabled(isForceUploading) .disabled(isForceUploading)
} }
} }
@@ -533,7 +533,7 @@ struct SettingsView: View {
if !viewModel.isPremium { if !viewModel.isPremium {
Text("Premium") Text("Premium")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundColor(.appWarning) .foregroundStyle(Color.appWarning)
} }
} }
} }
@@ -553,7 +553,7 @@ struct SettingsView: View {
Text("Last auto backup") Text("Last auto backup")
Spacer() Spacer()
Text(last, style: .date) Text(last, style: .date)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
.font(.caption) .font(.caption)
} }
@@ -580,7 +580,7 @@ struct SettingsView: View {
if !viewModel.canExport { if !viewModel.canExport {
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
.font(.caption) .font(.caption)
.foregroundColor(.appWarning) .foregroundStyle(Color.appWarning)
} }
} }
} }
@@ -598,21 +598,21 @@ struct SettingsView: View {
Text("Total Sources") Text("Total Sources")
Spacer() Spacer()
Text("\(viewModel.totalSources)") Text("\(viewModel.totalSources)")
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
HStack { HStack {
Text("Total Snapshots") Text("Total Snapshots")
Spacer() Spacer()
Text("\(viewModel.totalSnapshots)") Text("\(viewModel.totalSnapshots)")
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
HStack { HStack {
Text("Storage Used") Text("Storage Used")
Spacer() Spacer()
Text(viewModel.storageUsedText) Text(viewModel.storageUsedText)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} header: { } header: {
Text("Data") Text("Data")
@@ -648,7 +648,7 @@ struct SettingsView: View {
if viewModel.backups.isEmpty { if viewModel.backups.isEmpty {
Text("No backups yet.") Text("No backups yet.")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} else { } else {
ForEach(viewModel.backups) { backup in ForEach(viewModel.backups) { backup in
HStack { HStack {
@@ -657,7 +657,7 @@ struct SettingsView: View {
.font(.subheadline) .font(.subheadline)
Text("\(backup.location.rawValue) · \(formatBytes(backup.size))") Text("\(backup.location.rawValue) · \(formatBytes(backup.size))")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
Button("Restore") { Button("Restore") {
@@ -685,7 +685,7 @@ struct SettingsView: View {
Section { Section {
Toggle("Hide Balances", isOn: $balancePrivacy.balancesHidden) 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 .onChange(of: balancePrivacy.requireBiometricToReveal) { _, enabled in
if enabled && !AppLockService.canUseBiometrics() { if enabled && !AppLockService.canUseBiometrics() {
balancePrivacy.requireBiometricToReveal = false balancePrivacy.requireBiometricToReveal = false
@@ -695,10 +695,13 @@ struct SettingsView: View {
} header: { } header: {
Text("Privacy") Text("Privacy")
} footer: { } 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 { private var securitySection: some View {
Section { Section {
Toggle("Require PIN", isOn: $pinEnabled) 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 .onChange(of: faceIdEnabled) { _, enabled in
if enabled { if enabled {
guard AppLockService.canUseBiometrics() else { guard AppLockService.canUseBiometrics() else {
@@ -748,7 +751,7 @@ struct SettingsView: View {
} header: { } header: {
Text("App Lock") Text("App Lock")
} footer: { } 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") Label("Allocation Targets", systemImage: "target")
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
} header: { } header: {
@@ -825,7 +828,7 @@ struct SettingsView: View {
if !viewModel.isPremium { if !viewModel.isPremium {
Text("Premium") Text("Premium")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
} }
@@ -844,7 +847,7 @@ struct SettingsView: View {
Text("Version") Text("Version")
Spacer() Spacer()
Text(viewModel.appVersion) Text(viewModel.appVersion)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
Link(destination: URL(string: AppConstants.URLs.privacyPolicy)!) { Link(destination: URL(string: AppConstants.URLs.privacyPolicy)!) {
@@ -853,7 +856,7 @@ struct SettingsView: View {
Spacer() Spacer()
Image(systemName: "arrow.up.right") Image(systemName: "arrow.up.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -863,7 +866,7 @@ struct SettingsView: View {
Spacer() Spacer()
Image(systemName: "arrow.up.right") Image(systemName: "arrow.up.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -873,7 +876,7 @@ struct SettingsView: View {
Spacer() Spacer()
Image(systemName: "arrow.up.right") Image(systemName: "arrow.up.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -884,7 +887,7 @@ struct SettingsView: View {
Text("Rate App") Text("Rate App")
Spacer() Spacer()
Image(systemName: "star.fill") Image(systemName: "star.fill")
.foregroundColor(.yellow) .foregroundStyle(.yellow)
} }
} }
} header: { } header: {
@@ -992,7 +995,7 @@ struct ExportOptionsSheet: View {
Text(viewModel.exportStatus) Text(viewModel.exportStatus)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} header: { } header: {
@@ -1005,7 +1008,7 @@ struct ExportOptionsSheet: View {
} label: { } label: {
HStack { HStack {
Image(systemName: "tablecells") Image(systemName: "tablecells")
.foregroundColor(.positiveGreen) .foregroundStyle(Color.positiveGreen)
.frame(width: 30) .frame(width: 30)
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -1013,7 +1016,7 @@ struct ExportOptionsSheet: View {
.font(.headline) .font(.headline)
Text("Compatible with Excel, Google Sheets") Text("Compatible with Excel, Google Sheets")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
} }
@@ -1023,7 +1026,7 @@ struct ExportOptionsSheet: View {
} label: { } label: {
HStack { HStack {
Image(systemName: "doc.text") Image(systemName: "doc.text")
.foregroundColor(.appPrimary) .foregroundStyle(Color.appPrimary)
.frame(width: 30) .frame(width: 30)
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -1031,7 +1034,7 @@ struct ExportOptionsSheet: View {
.font(.headline) .font(.headline)
Text("Full data structure for backup") Text("Full data structure for backup")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundStyle(.secondary)
} }
} }
} }
@@ -1043,7 +1046,7 @@ struct ExportOptionsSheet: View {
.navigationTitle(viewModel.isExporting ? "Exporting" : "Export Data") .navigationTitle(viewModel.isExporting ? "Exporting" : "Export Data")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button("Cancel") { Button("Cancel") {
dismiss() dismiss()
} }
@@ -1089,16 +1092,16 @@ struct PinSetupView: View {
if let errorMessage { if let errorMessage {
Text(errorMessage) Text(errorMessage)
.font(.caption) .font(.caption)
.foregroundColor(.negativeRed) .foregroundStyle(Color.negativeRed)
} }
} }
.navigationTitle(title) .navigationTitle(title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() } Button("Cancel") { dismiss() }
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button("Save") { savePin() } Button("Save") { savePin() }
.disabled(pin.count < 4 || confirmPin.count < 4) .disabled(pin.count < 4 || confirmPin.count < 4)
} }
@@ -1142,19 +1145,19 @@ struct PinVerifyView: View {
if let errorMessage { if let errorMessage {
Text(errorMessage) Text(errorMessage)
.font(.caption) .font(.caption)
.foregroundColor(.negativeRed) .foregroundStyle(Color.negativeRed)
} }
} }
.navigationTitle(title) .navigationTitle(title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { Button("Cancel") {
onResult(false) onResult(false)
dismiss() dismiss()
} }
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button("Verify") { verifyPin() } Button("Verify") { verifyPin() }
.disabled(pin.count < 4) .disabled(pin.count < 4)
} }