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:
Robin Kutesa
2026-05-16 16:20:53 +03:00
parent 03d63e60de
commit ae38f6e417
38 changed files with 1649 additions and 451 deletions

View File

@@ -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") ?? ""

View 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)
}
}

View 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)
}
}