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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
46
Shared/Components/UGreenCompatibilityBadge.swift
Normal file
46
Shared/Components/UGreenCompatibilityBadge.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
30
Shared/Extensions/Date+.swift
Normal file
30
Shared/Extensions/Date+.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
14
Shared/Extensions/PHAsset+.swift
Normal file
14
Shared/Extensions/PHAsset+.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
14
Shared/Extensions/URL+.swift
Normal file
14
Shared/Extensions/URL+.swift
Normal 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: "_")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user