feat: Kisani rebrand, step-based onboarding flow, dark mode fixes
- Rename app to Kisani in all UI text and Face ID prompt - Replace flat 2-state RootView with 5-step flow: ConnectView → LoginView → FolderSetupView → PhotoPermissionView → MainTabView - Remove NavigationStack from LoginView (was causing nested-stack crash); drive navigation via ConnectionStore.isSessionActive - Add FolderSetupView and PhotoPermissionView onboarding screens with step indicator (2/3, 3/3), spring entrance animations, and UGreen connection chip - Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25) - Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light) - Add ConnectionStore.isSessionActive (non-persisted) and onboardingPhotoShown (UserDefaults-persisted) - Add os_log logging in LoginViewModel at auth start/success/failure - Remove dead Help button from ConnectView - UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions, PrivacyInfo.xcprivacy, unit test target and test stubs - AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color - AppearanceMode enum + segmented picker in SettingsView - BackupView: enlarged ring, removed options card and linear progress bar - HistoryView: duration and bytes formatting, improved typography hierarchy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,10 @@ final class ConnectionStore: ObservableObject {
|
||||
static let shared = ConnectionStore()
|
||||
|
||||
@Published var savedConnection: NASConnection? {
|
||||
didSet { saveUD(savedConnection, key: "savedConnection") }
|
||||
didSet {
|
||||
if let conn = savedConnection { SavedConnections.save(conn) }
|
||||
else { SavedConnections.delete() }
|
||||
}
|
||||
}
|
||||
@Published var backupFilter: BackupFilter {
|
||||
didSet { saveUD(backupFilter, key: "backupFilter") }
|
||||
@@ -42,6 +45,19 @@ final class ConnectionStore: ObservableObject {
|
||||
didSet { UserDefaults.standard.set(quietHoursEnd, forKey: "quietHoursEnd") }
|
||||
}
|
||||
|
||||
// Session — not persisted, resets on every app launch
|
||||
@Published var isSessionActive = false
|
||||
|
||||
// Onboarding — persisted so we only show each step once
|
||||
@Published var onboardingPhotoShown: Bool {
|
||||
didSet { UserDefaults.standard.set(onboardingPhotoShown, forKey: "onboardingPhotoShown") }
|
||||
}
|
||||
|
||||
// Appearance
|
||||
@Published var appearanceMode: AppearanceMode {
|
||||
didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: "appearanceMode") }
|
||||
}
|
||||
|
||||
// Cellular & remote access
|
||||
@Published var allowCellularBackup: Bool {
|
||||
didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") }
|
||||
@@ -56,7 +72,7 @@ final class ConnectionStore: ObservableObject {
|
||||
@Published private(set) var historyEntries: [BackupHistoryEntry] = []
|
||||
|
||||
private init() {
|
||||
savedConnection = ud(NASConnection.self, key: "savedConnection")
|
||||
savedConnection = SavedConnections.load()
|
||||
backupFilter = ud(BackupFilter.self, key: "backupFilter") ?? BackupFilter()
|
||||
trustedSSIDs = UserDefaults.standard.stringArray(forKey: "trustedSSIDs") ?? []
|
||||
lanTriggerEnabled = UserDefaults.standard.object(forKey: "lanTriggerEnabled") as? Bool ?? true
|
||||
@@ -65,6 +81,8 @@ final class ConnectionStore: ObservableObject {
|
||||
quietHoursEnabled = UserDefaults.standard.object(forKey: "quietHoursEnabled") as? Bool ?? false
|
||||
quietHoursStart = UserDefaults.standard.object(forKey: "quietHoursStart") as? Int ?? 23
|
||||
quietHoursEnd = UserDefaults.standard.object(forKey: "quietHoursEnd") as? Int ?? 7
|
||||
onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown")
|
||||
appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system
|
||||
allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false
|
||||
useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false
|
||||
tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? ""
|
||||
|
||||
48
Shared/Persistence/KeychainStore.swift
Normal file
48
Shared/Persistence/KeychainStore.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainStore {
|
||||
private static func key(for id: UUID) -> String {
|
||||
"com.nasbackup.connection.\(id.uuidString)"
|
||||
}
|
||||
|
||||
static func savePassword(_ password: String, for id: UUID) {
|
||||
let account = key(for: id)
|
||||
let data = Data(password.utf8)
|
||||
|
||||
let deleteQuery: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: account
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
let addQuery: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
kSecValueData: data
|
||||
]
|
||||
SecItemAdd(addQuery as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func loadPassword(for id: UUID) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: key(for: id),
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne
|
||||
]
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func deletePassword(for id: UUID) {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: key(for: id)
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
30
Shared/Persistence/SavedConnections.swift
Normal file
30
Shared/Persistence/SavedConnections.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
// Bridges NASConnection save/load with Keychain for password and UserDefaults for metadata.
|
||||
enum SavedConnections {
|
||||
private static let key = "savedConnection"
|
||||
|
||||
static func save(_ connection: NASConnection) {
|
||||
KeychainStore.savePassword(connection.password, for: connection.id)
|
||||
if let data = try? JSONEncoder().encode(connection) {
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
static func load() -> NASConnection? {
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
var connection = try? JSONDecoder().decode(NASConnection.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
connection.password = KeychainStore.loadPassword(for: connection.id) ?? ""
|
||||
return connection
|
||||
}
|
||||
|
||||
static func delete() {
|
||||
if let data = UserDefaults.standard.data(forKey: key),
|
||||
let connection = try? JSONDecoder().decode(NASConnection.self, from: data) {
|
||||
KeychainStore.deletePassword(for: connection.id)
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user