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

@@ -1,6 +1,6 @@
import SwiftUI
// MARK: Primary (black fill)
// MARK: Primary (ink fill)
struct PrimaryButtonStyle: ButtonStyle {
var color: Color = AppTheme.ink
var height: CGFloat = 50
@@ -8,7 +8,7 @@ struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white)
.foregroundStyle(AppTheme.inkInverse)
.frame(maxWidth: .infinity)
.frame(height: height)
.background(color)
@@ -25,12 +25,12 @@ struct GhostButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 15, weight: .medium))
.foregroundColor(AppTheme.ink)
.foregroundStyle(AppTheme.ink)
.frame(maxWidth: .infinity)
.frame(height: height)
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.stroke(AppTheme.cardBorderColor, lineWidth: 1)
.stroke(AppTheme.ink.opacity(0.25), lineWidth: 1)
)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
@@ -52,7 +52,7 @@ struct PillButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.foregroundStyle(AppTheme.inkInverse)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(color)

View File

@@ -29,6 +29,8 @@ struct ToggleRow: View {
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(AppTheme.ink)
.accessibilityLabel(title)
.accessibilityValue(isOn ? "On" : "Off")
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)

View File

@@ -0,0 +1,46 @@
import SwiftUI
import UIKit
// Displays the UGreen NAS compatibility indicator.
// If you add an image named "ugreen_logo" to Assets.xcassets it will be used automatically.
// Otherwise a brand-styled text badge is shown.
struct UGreenCompatibilityBadge: View {
private static let ugreenGreen = Color(hex: "#00B388")
private let hasLogo = UIImage(named: "ugreen_logo") != nil
var body: some View {
HStack(spacing: 8) {
// UGreen logo or fallback wordmark
if hasLogo {
Image("ugreen_logo")
.resizable()
.scaledToFit()
.frame(height: 14)
} else {
HStack(spacing: 4) {
// Brand dot
Circle()
.fill(Self.ugreenGreen)
.frame(width: 6, height: 6)
Text("UGreen NAS")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Self.ugreenGreen)
}
}
// Separator
Rectangle()
.fill(AppTheme.inkQuaternary)
.frame(width: 0.5, height: 11)
// Protocol hint
Text("SMB · SFTP")
.font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(AppTheme.surfaceSunken)
.clipShape(Capsule(style: .continuous))
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
extension Date {
private static let friendlyFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
return f
}()
var friendlyString: String {
Date.friendlyFormatter.string(from: self)
}
var relativeString: String {
Date.relativeFormatter.localizedString(for: self, relativeTo: Date())
}
var backupTimestamp: String {
let f = DateFormatter()
f.dateFormat = "yyyyMMdd_HHmmss"
return f.string(from: self)
}
}

View File

@@ -0,0 +1,14 @@
import Photos
extension PHAsset {
var primaryFilename: String {
let resources = PHAssetResource.assetResources(for: self)
return resources.first?.originalFilename
?? "\(localIdentifier.prefix(8)).jpg"
}
var isRAWCapture: Bool {
PHAssetResource.assetResources(for: self)
.contains { $0.type == .alternatePhoto }
}
}

View File

@@ -0,0 +1,14 @@
import Foundation
extension URL {
var fileSize: Int64 {
(try? resourceValues(forKeys: [.fileSizeKey]).fileSize)
.flatMap { Int64($0) } ?? 0
}
var sanitizedFilename: String {
lastPathComponent
.components(separatedBy: .init(charactersIn: "/\\:*?\"<>|"))
.joined(separator: "_")
}
}

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

View File

@@ -1,55 +1,107 @@
import SwiftUI
import UIKit
// MARK: Appearance preference
enum AppearanceMode: String, CaseIterable {
case system, light, dark
var resolvedScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
var label: String {
switch self {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
var icon: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .light: return "sun.max"
case .dark: return "moon"
}
}
}
enum AppTheme {
// MARK: Surfaces
static let background = Color.white
static let surfaceRaised = Color.white
static let surfaceSunken = Color(hex: "#F5F5F5")
static let background = Color(UIColor.systemBackground)
static let surfaceRaised = Color(UIColor.systemBackground)
static let surfaceSunken = Color(UIColor { t in
t.userInterfaceStyle == .dark
? UIColor(white: 0.11, alpha: 1)
: UIColor(white: 0.961, alpha: 1)
})
// MARK: Ink
static let ink = Color(hex: "#0F0F0F")
static let inkSecondary = Color(hex: "#6B6B6B")
static let inkTertiary = Color(hex: "#ABABAB")
static let inkQuaternary = Color(hex: "#D4D4D4")
static let ink = Color(UIColor { t in
t.userInterfaceStyle == .dark
? UIColor(white: 0.93, alpha: 1)
: UIColor(red: 0.059, green: 0.059, blue: 0.059, alpha: 1)
})
static let inkSecondary = Color(UIColor { t in
t.userInterfaceStyle == .dark
? UIColor(white: 0.58, alpha: 1)
: UIColor(white: 0.42, alpha: 1)
})
static let inkTertiary = Color(UIColor { t in
t.userInterfaceStyle == .dark
? UIColor(white: 0.40, alpha: 1)
: UIColor(white: 0.67, alpha: 1)
})
static let inkQuaternary = Color(UIColor { t in
t.userInterfaceStyle == .dark
? UIColor(white: 0.22, alpha: 1)
: UIColor(white: 0.83, alpha: 1)
})
// Readable on AppTheme.ink background in both color schemes
static let inkInverse = Color(UIColor { t in
t.userInterfaceStyle == .dark
? UIColor(red: 0.059, green: 0.059, blue: 0.059, alpha: 1)
: UIColor(white: 1.0, alpha: 1)
})
// MARK: Semantic
static let positive = Color(hex: "#16A34A")
static let destructive = Color(hex: "#DC2626")
static let interactive = Color(hex: "#2563EB")
static let positive = Color(hex: "#16A34A")
static let destructive = Color(hex: "#DC2626")
static let interactive = Color(hex: "#2563EB")
// MARK: Chrome
static let separator = Color.black.opacity(0.07)
static let cardShadow = Color.black.opacity(0.05)
static let cardBorderColor = Color.black.opacity(0.07)
static let separator = Color(UIColor.separator)
static let cardShadow = Color(UIColor { t in
t.userInterfaceStyle == .dark
? UIColor.clear
: UIColor.black.withAlphaComponent(0.048)
})
static let cardBorderColor = Color(UIColor.separator)
// MARK: Metrics
static let radius: CGFloat = 14
static let radiusLarge: CGFloat = 18
static let hPad: CGFloat = 20
static let cardPad: CGFloat = 16
static let sectionGap: CGFloat = 28
static let radius: CGFloat = 14
static let radiusLarge: CGFloat = 18
static let hPad: CGFloat = 20
static let cardPad: CGFloat = 16
static let sectionGap: CGFloat = 28
// MARK: Typography
static func display(_ size: CGFloat = 32) -> Font {
.system(size: size, weight: .semibold, design: .default)
}
static func title(_ size: CGFloat = 20) -> Font {
.system(size: size, weight: .semibold)
}
static func headline(_ size: CGFloat = 15) -> Font {
.system(size: size, weight: .medium)
}
static func body(_ size: CGFloat = 15) -> Font {
.system(size: size, weight: .regular)
}
static func caption(_ size: CGFloat = 13) -> Font {
.system(size: size, weight: .regular)
}
static func micro(_ size: CGFloat = 11) -> Font {
.system(size: size, weight: .medium)
}
static func title(_ size: CGFloat = 20) -> Font { .system(size: size, weight: .semibold) }
static func headline(_ size: CGFloat = 15) -> Font { .system(size: size, weight: .medium) }
static func body(_ size: CGFloat = 15) -> Font { .system(size: size, weight: .regular) }
static func caption(_ size: CGFloat = 13) -> Font { .system(size: size, weight: .regular) }
static func micro(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .medium) }
// MARK: Back-compat aliases used by existing call sites
// MARK: Back-compat aliases
static let blue = interactive
static let green = positive
static let red = destructive
@@ -82,9 +134,7 @@ struct CardStyle: ViewModifier {
}
extension View {
func card(padded: Bool = true) -> some View {
modifier(CardStyle(padded: padded))
}
func card(padded: Bool = true) -> some View { modifier(CardStyle(padded: padded)) }
@ViewBuilder func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
if condition { transform(self) } else { self }