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