From 6774d7614626c1e80ce7985db0248623cf9700c9 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Wed, 16 Sep 2026 11:44:50 +0200 Subject: [PATCH] =?UTF-8?q?Biometr=C3=ADa=20gen=C3=A9rica=20(Face=20ID=20/?= =?UTF-8?q?=20Touch=20ID=20/=20Optic=20ID)=20y=20What's=20New=201.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn --- .../Resources/de.lproj/Localizable.strings | 23 ++++ .../Resources/en.lproj/Localizable.strings | 23 ++++ .../Resources/es-ES.lproj/Localizable.strings | 23 ++++ .../Resources/fr.lproj/Localizable.strings | 23 ++++ .../Resources/it.lproj/Localizable.strings | 23 ++++ .../Resources/ja.lproj/Localizable.strings | 23 ++++ .../Resources/pt-BR.lproj/Localizable.strings | 23 ++++ .../Utilities/AppLockService.swift | 48 +++++++ .../Views/Onboarding/WhatsNewView.swift | 34 ++++- .../Views/Security/AppLockView.swift | 16 ++- .../Views/Settings/SettingsView.swift | 127 +++++++++--------- 11 files changed, 310 insertions(+), 76 deletions(-) diff --git a/PortfolioJournal/Resources/de.lproj/Localizable.strings b/PortfolioJournal/Resources/de.lproj/Localizable.strings index fe3779f..ab163ed 100644 --- a/PortfolioJournal/Resources/de.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/de.lproj/Localizable.strings @@ -684,3 +684,26 @@ "goal_scope_all_categories" = "Alle Kategorien"; "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."; + +// 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."; diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index ace8b9e..d9273b7 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -685,3 +685,26 @@ "goal_scope_all_categories" = "All categories"; "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."; + +// 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."; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index 5c57c5a..7563f35 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -636,3 +636,26 @@ "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_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."; diff --git a/PortfolioJournal/Resources/fr.lproj/Localizable.strings b/PortfolioJournal/Resources/fr.lproj/Localizable.strings index 55165e8..f0a241c 100644 --- a/PortfolioJournal/Resources/fr.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/fr.lproj/Localizable.strings @@ -684,3 +684,26 @@ "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_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."; diff --git a/PortfolioJournal/Resources/it.lproj/Localizable.strings b/PortfolioJournal/Resources/it.lproj/Localizable.strings index f4c0c6a..9c0e661 100644 --- a/PortfolioJournal/Resources/it.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/it.lproj/Localizable.strings @@ -684,3 +684,26 @@ "goal_scope_all_categories" = "Tutte le categorie"; "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."; + +// 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."; diff --git a/PortfolioJournal/Resources/ja.lproj/Localizable.strings b/PortfolioJournal/Resources/ja.lproj/Localizable.strings index 2eede86..03e83f1 100644 --- a/PortfolioJournal/Resources/ja.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/ja.lproj/Localizable.strings @@ -684,3 +684,26 @@ "goal_scope_all_categories" = "すべてのカテゴリ"; "goal_scope_footer_all" = "ポートフォリオのすべての投資先がこの目標に加算されます。"; "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" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。"; diff --git a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings index fad724f..beed04f 100644 --- a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings @@ -684,3 +684,26 @@ "goal_scope_all_categories" = "Todas as categorias"; "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."; + +// 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."; diff --git a/PortfolioJournal/Utilities/AppLockService.swift b/PortfolioJournal/Utilities/AppLockService.swift index 6d08ddf..a2f6e56 100644 --- a/PortfolioJournal/Utilities/AppLockService.swift +++ b/PortfolioJournal/Utilities/AppLockService.swift @@ -1,6 +1,36 @@ import Foundation 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 { static func canUseBiometrics() -> Bool { let context = LAContext() @@ -8,6 +38,24 @@ enum AppLockService { 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) { let context = LAContext() context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in diff --git a/PortfolioJournal/Views/Onboarding/WhatsNewView.swift b/PortfolioJournal/Views/Onboarding/WhatsNewView.swift index e8c7b0a..ca00c53 100644 --- a/PortfolioJournal/Views/Onboarding/WhatsNewView.swift +++ b/PortfolioJournal/Views/Onboarding/WhatsNewView.swift @@ -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() } diff --git a/PortfolioJournal/Views/Security/AppLockView.swift b/PortfolioJournal/Views/Security/AppLockView.swift index 0e6650b..ee3f28e 100644 --- a/PortfolioJournal/Views/Security/AppLockView.swift +++ b/PortfolioJournal/Views/Security/AppLockView.swift @@ -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) } } } diff --git a/PortfolioJournal/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift index bd42264..639f9a9 100644 --- a/PortfolioJournal/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -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) }