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,4 +1,5 @@
import SwiftUI import SwiftUI
import Photos
@main @main
struct NASBackupApp: App { struct NASBackupApp: App {
@@ -14,6 +15,7 @@ struct NASBackupApp: App {
.environmentObject(store) .environmentObject(store)
.environmentObject(engine) .environmentObject(engine)
.environmentObject(lanMonitor) .environmentObject(lanMonitor)
.preferredColorScheme(store.appearanceMode.resolvedScheme)
} }
} }
} }
@@ -22,10 +24,21 @@ struct RootView: View {
@EnvironmentObject var store: ConnectionStore @EnvironmentObject var store: ConnectionStore
var body: some View { var body: some View {
if store.savedConnection != nil { Group {
LoginView() if store.savedConnection == nil {
} else {
ConnectView() ConnectView()
} else if !store.isSessionActive {
LoginView()
} else if store.savedConnection?.remotePath == "/" {
FolderSetupView()
} else if !store.onboardingPhotoShown {
PhotoPermissionView()
} else {
MainTabView()
} }
} }
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: store.isSessionActive)
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: store.savedConnection?.remotePath)
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: store.onboardingPhotoShown)
}
} }

44
App/PrivacyInfo.xcprivacy Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePhotosorVideos</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict>
</plist>

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "ugreen_logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ugreen_logo 1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ugreen_logo 2.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -10,6 +10,8 @@ enum NASProtocol: String, Codable, CaseIterable {
case .sftp: return 22 case .sftp: return 22
} }
} }
var localizedDescription: String { rawValue }
} }
struct NASConnection: Codable, Identifiable, Equatable { struct NASConnection: Codable, Identifiable, Equatable {
@@ -18,10 +20,15 @@ struct NASConnection: Codable, Identifiable, Equatable {
var port: Int var port: Int
var nasProtocol: NASProtocol var nasProtocol: NASProtocol
var username: String var username: String
var password: String var password: String // Runtime only persisted in Keychain, not UserDefaults
var remotePath: String var remotePath: String
var displayName: String var displayName: String
// Excludes password so it is never written to UserDefaults.
enum CodingKeys: String, CodingKey {
case id, host, port, nasProtocol, username, remotePath, displayName
}
init( init(
host: String, host: String,
port: Int? = nil, port: Int? = nil,
@@ -39,4 +46,16 @@ struct NASConnection: Codable, Identifiable, Equatable {
self.remotePath = remotePath self.remotePath = remotePath
self.displayName = displayName.isEmpty ? host : displayName self.displayName = displayName.isEmpty ? host : displayName
} }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(UUID.self, forKey: .id)
host = try c.decode(String.self, forKey: .host)
port = try c.decode(Int.self, forKey: .port)
nasProtocol = try c.decode(NASProtocol.self, forKey: .nasProtocol)
username = try c.decode(String.self, forKey: .username)
remotePath = try c.decode(String.self, forKey: .remotePath)
displayName = try c.decode(String.self, forKey: .displayName)
password = "" // filled from Keychain by SavedConnections.load()
}
} }

View File

@@ -8,43 +8,81 @@ struct BackupView: View {
var body: some View { var body: some View {
ZStack { ZStack {
Color.white.ignoresSafeArea() AppTheme.background.ignoresSafeArea()
ScrollView { VStack(spacing: 0) {
VStack(spacing: AppTheme.sectionGap) { Spacer(minLength: 0)
ringSection
nasRow // Ring hero
optionsCard ringHero
actionButton .padding(.bottom, 28)
// Completion summary (only on .completed)
if case .completed = engine.job.status {
completionStrip
.padding(.bottom, 28)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
} }
Spacer(minLength: 0)
// NAS status
nasStatusRow
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.top, 24) .padding(.bottom, 16)
.padding(.bottom, 48)
// Photos access nudge
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
photosAccessRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 16)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// Action
actionButton
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 32)
} }
} }
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed)
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
.task { vm.loadPhotoCount(filter: store.backupFilter) } .task { vm.loadPhotoCount(filter: store.backupFilter) }
} }
// MARK: Ring // MARK: Ring hero
private var ringSection: some View { private var ringHero: some View {
VStack(spacing: 20) {
ZStack { ZStack {
ProgressRing( ProgressRing(
progress: engine.job.progress, progress: engine.job.progress,
lineWidth: 8, lineWidth: 7,
size: 180, size: 220,
color: ringColor, color: ringColor,
backgroundColor: Color(hex: "#EBEBEB") backgroundColor: AppTheme.inkQuaternary.opacity(0.4)
) )
.accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(engine.job.progress * 100)) percent")
VStack(spacing: 3) { VStack(spacing: 4) {
Text(percentText) Text(percentText)
.font(.system(size: 40, weight: .semibold, design: .rounded)) .font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
.contentTransition(.numericText()) .contentTransition(.numericText())
.monospacedDigit()
if case .running = engine.job.status { ringSubtitle
.transition(.opacity)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
@ViewBuilder
private var ringSubtitle: some View {
switch engine.job.status {
case .running:
VStack(spacing: 2) {
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)") Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
.font(AppTheme.caption()) .font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkSecondary)
@@ -53,62 +91,35 @@ struct BackupView: View {
.font(.system(size: 11, weight: .regular)) .font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
} }
} else {
Text("\(vm.totalPhotoCount) photos")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
}
}
}
// Completion summary
if case .completed = engine.job.status {
HStack(spacing: 0) {
statCell("\(engine.job.uploadedFiles)", label: "uploaded")
dividerLine
statCell("\(engine.job.skippedFiles)", label: "skipped")
dividerLine
statCell("\(engine.job.failedFiles)", label: "errors",
color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkTertiary)
}
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
// Progress detail
if case .running = engine.job.status {
VStack(spacing: 8) {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color(hex: "#EBEBEB"))
.frame(height: 3)
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(AppTheme.ink)
.frame(width: geo.size.width * engine.job.progress, height: 3)
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
}
.frame(height: 3)
HStack {
Text(engine.job.currentFileName) Text(engine.job.currentFileName)
.font(.system(size: 12, weight: .regular)) .font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkTertiary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.middle) .truncationMode(.middle)
Spacer() .frame(maxWidth: 160)
Text(formatSpeed(engine.job.speedBytesPerSecond)) }
.font(.system(size: 12, weight: .regular)) case .preparing:
Text("Preparing…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
case .completed:
Text("Done")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.positive)
case .failed:
Text("Failed")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.destructive)
case .cancelled:
Text("Cancelled")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
default:
Text(vm.totalPhotoCount > 0 ? "\(vm.totalPhotoCount) photos ready" : "Ready")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
} }
} }
.transition(.opacity)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.status == .completed)
}
private var ringColor: Color { private var ringColor: Color {
switch engine.job.status { switch engine.job.status {
@@ -120,11 +131,32 @@ struct BackupView: View {
private var percentText: String { "\(Int(engine.job.progress * 100))%" } private var percentText: String { "\(Int(engine.job.progress * 100))%" }
private func statCell(_ value: String, label: String, color: Color = AppTheme.ink) -> some View { // MARK: Completion strip
VStack(spacing: 2) {
private var completionStrip: some View {
HStack(spacing: 0) {
statCell("\(engine.job.uploadedFiles)", label: "uploaded")
thinDivider
statCell("\(engine.job.skippedFiles)", label: "skipped")
thinDivider
statCell(
"\(engine.job.failedFiles)",
label: "errors",
valueColor: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink
)
}
.padding(.horizontal, AppTheme.hPad)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
}
private func statCell(_ value: String, label: String, valueColor: Color = AppTheme.ink) -> some View {
VStack(spacing: 3) {
Text(value) Text(value)
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 17, weight: .semibold))
.foregroundStyle(color) .foregroundStyle(valueColor)
.monospacedDigit()
Text(label) Text(label)
.font(AppTheme.micro()) .font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
@@ -133,21 +165,21 @@ struct BackupView: View {
.padding(.vertical, 14) .padding(.vertical, 14)
} }
private var dividerLine: some View { private var thinDivider: some View {
Rectangle() Rectangle()
.fill(AppTheme.separator) .fill(AppTheme.separator)
.frame(width: 0.5) .frame(width: 0.5)
.padding(.vertical, 12) .padding(.vertical, 10)
} }
// MARK: NAS row // MARK: NAS status row
private var nasRow: some View { private var nasStatusRow: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
ZStack { ZStack {
Circle() Circle()
.fill(AppTheme.surfaceSunken) .fill(AppTheme.surfaceSunken)
.frame(width: 32, height: 32) .frame(width: 34, height: 34)
Image(systemName: "externaldrive") Image(systemName: "externaldrive")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkSecondary)
@@ -158,98 +190,54 @@ struct BackupView: View {
Text(conn.host) Text(conn.host)
.font(AppTheme.headline()) .font(AppTheme.headline())
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
Text(conn.username) Text(conn.nasProtocol.rawValue)
.font(AppTheme.caption()) .font(AppTheme.micro(10))
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkTertiary)
} }
} else {
Text("No NAS configured")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkTertiary)
} }
Spacer() Spacer()
HStack(spacing: 5) { HStack(spacing: 5) {
Circle() Circle()
.fill(AppTheme.positive) .fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary)
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
Text("Live") Text(engine.job.status.isActive ? "Active" : "Ready")
.font(AppTheme.micro()) .font(AppTheme.micro())
.foregroundStyle(AppTheme.positive) .foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary)
} }
} }
.padding(14) .padding(.horizontal, 14)
.padding(.vertical, 12)
.background(AppTheme.surfaceSunken) .background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
} }
// MARK: Options // MARK: Photos access nudge
private var optionsCard: some View { private var photosAccessRow: some View {
VStack(spacing: 0) {
optionRow(icon: "camera", label: "What to back up") {}
hairline
optionRow(icon: "doc.badge.clock", label: "New files only") {}
hairline
Button(action: { Task { await vm.requestPhotosAccess() } }) { Button(action: { Task { await vm.requestPhotosAccess() } }) {
HStack { HStack(spacing: 10) {
Image(systemName: "photo.on.rectangle") Image(systemName: "photo.on.rectangle")
.font(.system(size: 14, weight: .regular)) .font(.system(size: 13, weight: .medium))
.foregroundStyle(vm.photosAuthStatus == .authorized .foregroundStyle(AppTheme.destructive)
? AppTheme.inkSecondary : AppTheme.destructive)
.frame(width: 20) .frame(width: 20)
Text("Photos access") Text("Grant photo library access to enable backup")
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Spacer()
Text(photosLabel)
.font(AppTheme.caption()) .font(AppTheme.caption())
.foregroundStyle(vm.photosAuthStatus == .authorized
? AppTheme.positive : AppTheme.destructive)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.buttonStyle(ScaleButtonStyle())
}
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
private var photosLabel: String {
switch vm.photosAuthStatus {
case .authorized: return "Full access"
case .limited: return "Limited"
case .denied: return "Denied"
default: return "Not set"
}
}
private var hairline: some View {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, 36)
}
private func optionRow(icon: String, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Image(systemName: icon)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
Text(label)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary) .foregroundStyle(AppTheme.inkQuaternary)
} }
.padding(.horizontal, 16) .padding(.horizontal, 14)
.padding(.vertical, 14) .padding(.vertical, 11)
.background(AppTheme.destructive.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
} }
.buttonStyle(ScaleButtonStyle()) .buttonStyle(ScaleButtonStyle())
} }
@@ -266,24 +254,27 @@ struct BackupView: View {
.buttonStyle(PrimaryButtonStyle( .buttonStyle(PrimaryButtonStyle(
color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink
)) ))
.accessibilityHint("Starts backing up your photos to the NAS")
case .running: case .running:
Button { engine.pause() } label: { Text("Pause") } Button { engine.pause() } label: { Text("Pause") }
.buttonStyle(GhostButtonStyle()) .buttonStyle(GhostButtonStyle())
.accessibilityLabel("Pause backup")
case .paused: case .paused:
HStack(spacing: 10) { HStack(spacing: 10) {
Button { engine.resume() } label: { Text("Resume") } Button { engine.resume() } label: { Text("Resume") }
.buttonStyle(PrimaryButtonStyle()) .buttonStyle(PrimaryButtonStyle())
.accessibilityLabel("Resume backup")
Button { engine.cancel() } label: { Text("Cancel") } Button { engine.cancel() } label: { Text("Cancel") }
.buttonStyle(GhostButtonStyle()) .buttonStyle(GhostButtonStyle())
.frame(width: 90) .frame(width: 90)
.accessibilityLabel("Cancel backup")
} }
case .preparing: case .preparing:
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
.scaleEffect(0.8)
Text("Preparing…") Text("Preparing…")
.font(AppTheme.body()) .font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkSecondary)
@@ -291,7 +282,7 @@ struct BackupView: View {
.frame(height: 50) .frame(height: 50)
} }
} }
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: engine.job.status == .running) .animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running)
} }
private func startBackup() { private func startBackup() {
@@ -299,12 +290,6 @@ struct BackupView: View {
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) } Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
} }
private func formatSpeed(_ bps: Double) -> String {
bps >= 1_000_000
? String(format: "%.1f MB/s", bps / 1_000_000)
: String(format: "%.0f KB/s", bps / 1_000)
}
private func etaString(_ seconds: TimeInterval) -> String { private func etaString(_ seconds: TimeInterval) -> String {
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s" Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
} }

View File

@@ -15,7 +15,7 @@ struct BrowseView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
Color.white.ignoresSafeArea() AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
// Path bar // Path bar
@@ -40,21 +40,31 @@ struct BrowseView: View {
if isLoading { if isLoading {
Spacer() Spacer()
VStack(spacing: 10) {
ProgressView() ProgressView()
.tint(AppTheme.inkTertiary) .tint(AppTheme.inkTertiary)
Text("Loading…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer() Spacer()
} else if let error { } else if let error {
Spacer() Spacer()
VStack(spacing: 12) { VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.system(size: 28, weight: .light)) .font(.system(size: 28, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary) .foregroundStyle(AppTheme.inkQuaternary)
VStack(spacing: 4) {
Text("Could not load folder")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
Text(error) Text(error)
.font(AppTheme.caption()) .font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkTertiary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
.padding() }
.padding(.horizontal, 32)
Spacer() Spacer()
} else { } else {
List { List {

View File

@@ -9,30 +9,33 @@ struct ConnectView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
AppTheme.surfaceSunken.ignoresSafeArea() AppTheme.background.ignoresSafeArea()
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
// Logo + title // Header
VStack(spacing: 10) { VStack(spacing: 20) {
// App icon
Image("nas_logo_clean") Image("nas_logo_clean")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 44, height: 44) .frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous))
.shadow(color: .black.opacity(0.08), radius: 14, x: 0, y: 4)
.padding(.top, 44) .padding(.top, 44)
Text("NAS Connect") // Title
VStack(spacing: 6) {
Text("Kisani")
.font(.system(size: 26, weight: .semibold)) .font(.system(size: 26, weight: .semibold))
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
Text("Connect to your UGreen or any SMB / SFTP NAS") // UGreen NAS compatibility badge
.font(AppTheme.caption()) UGreenCompatibilityBadge()
.foregroundStyle(AppTheme.inkTertiary)
.multilineTextAlignment(.center)
} }
.padding(.bottom, 32) }
.padding(.bottom, 28)
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
// Protocol picker // Protocol picker
@@ -50,7 +53,7 @@ struct ConnectView: View {
.padding(.vertical, 7) .padding(.vertical, 7)
.background( .background(
RoundedRectangle(cornerRadius: 7, style: .continuous) RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(vm.selectedProtocol == proto ? Color.white : Color.clear) .fill(vm.selectedProtocol == proto ? AppTheme.surfaceRaised : Color.clear)
.shadow( .shadow(
color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear, color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
radius: 3, x: 0, y: 1 radius: 3, x: 0, y: 1
@@ -61,7 +64,7 @@ struct ConnectView: View {
} }
} }
.padding(3) .padding(3)
.background(Color.black.opacity(0.06)) .background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20) .padding(.bottom, 20)
@@ -90,18 +93,13 @@ struct ConnectView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 14)
.background(Color.white) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
HStack(spacing: 20) { Text("e.g. 192.168.1.10 or MY_NAS")
Text("IP Address, ex: 192.168.1.10")
.font(AppTheme.micro(11)) .font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
Text("NAS Name, ex: MY_NAS")
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkTertiary)
}
.padding(.leading, 4) .padding(.leading, 4)
} }
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
@@ -163,7 +161,7 @@ struct ConnectView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 14)
} }
.background(Color.white) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous) RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
@@ -199,7 +197,7 @@ struct ConnectView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 14)
.background(Color.white) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
} }
@@ -255,25 +253,11 @@ struct ConnectView: View {
.disabled(!vm.canConnect) .disabled(!vm.canConnect)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
// Help Spacer().frame(height: 48)
Button {
// future: show help sheet
} label: {
HStack(spacing: 6) {
Image(systemName: "questionmark.circle")
.font(.system(size: 13))
Text("Help")
.font(AppTheme.caption())
}
.foregroundStyle(AppTheme.inkTertiary)
}
.padding(.top, 20)
.padding(.bottom, 48)
} }
} }
} }
.navigationTitle("NAS Connect") .navigationBarHidden(true)
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() } .navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
.sheet(isPresented: $vm.showFolderBrowser) { .sheet(isPresented: $vm.showFolderBrowser) {
if let conn = ConnectionStore.shared.savedConnection { if let conn = ConnectionStore.shared.savedConnection {

View File

@@ -24,6 +24,11 @@ final class ConnectViewModel: ObservableObject {
} }
func verify() async { func verify() async {
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
await verifyWith(transfer: service)
}
func verifyWith(transfer: any NASTransferProtocol) async {
isVerifying = true isVerifying = true
isConnected = false isConnected = false
verifyError = nil verifyError = nil
@@ -36,11 +41,10 @@ final class ConnectViewModel: ObservableObject {
remotePath: remotePath remotePath: remotePath
) )
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
do { do {
try await service.connect(to: connection.host, port: connection.port, try await transfer.connect(to: connection.host, port: connection.port,
username: connection.username, password: connection.password) username: connection.username, password: connection.password)
service.disconnect() transfer.disconnect()
isConnected = true isConnected = true
ConnectionStore.shared.savedConnection = connection ConnectionStore.shared.savedConnection = connection
} catch let e as BackupError { } catch let e as BackupError {

View File

@@ -5,29 +5,22 @@ struct HistoryView: View {
var body: some View { var body: some View {
ZStack { ZStack {
Color.white.ignoresSafeArea() AppTheme.background.ignoresSafeArea()
if store.historyEntries.isEmpty { if store.historyEntries.isEmpty {
VStack(spacing: 12) { emptyState
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 36, weight: .light))
.foregroundStyle(AppTheme.inkQuaternary)
Text("No backup history yet")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkTertiary)
}
} else { } else {
List { List {
ForEach(store.historyEntries) { entry in ForEach(store.historyEntries) { entry in
HistoryRowView(entry: entry) HistoryRowView(entry: entry)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listRowBackground(AppTheme.background)
} }
.onDelete { offsets in .onDelete { store.removeHistoryEntries(at: $0) }
store.removeHistoryEntries(at: offsets)
}
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden)
} }
} }
.navigationTitle("History") .navigationTitle("History")
@@ -36,32 +29,60 @@ struct HistoryView: View {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Clear") { store.clearHistory() } Button("Clear") { store.clearHistory() }
.font(AppTheme.caption()) .font(AppTheme.caption())
.foregroundStyle(AppTheme.destructive) .foregroundStyle(AppTheme.inkTertiary)
} }
} }
} }
} }
private var emptyState: some View {
VStack(spacing: 14) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 32, weight: .light))
.foregroundStyle(AppTheme.inkQuaternary)
VStack(spacing: 5) {
Text("No backups yet")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
Text("Your backup history will appear here")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
} }
// MARK: Row
struct HistoryRowView: View { struct HistoryRowView: View {
let entry: BackupHistoryEntry let entry: BackupHistoryEntry
private var hasErrors: Bool { entry.failedCount > 0 } private var hasErrors: Bool { entry.failedCount > 0 }
private var statusColor: Color { hasErrors ? AppTheme.destructive : AppTheme.positive }
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(alignment: .top, spacing: 14) { HStack(alignment: .top, spacing: 14) {
// Status indicator
Circle() Circle()
.fill(hasErrors ? AppTheme.destructive : AppTheme.positive) .fill(statusColor)
.frame(width: 7, height: 7) .frame(width: 6, height: 6)
.padding(.top, 5) .padding(.top, 6)
// Content
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline, spacing: 8) { // Date + badges
Text(entry.date, style: .date) HStack(alignment: .firstTextBaseline, spacing: 6) {
.font(.system(size: 15, weight: .medium)) Text(entry.date, format: .dateTime.month(.abbreviated).day().year())
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
Text(entry.date, format: .dateTime.hour().minute())
.font(.system(size: 13, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
Spacer()
if entry.triggeredByLAN { if entry.triggeredByLAN {
Text("AUTO") Text("AUTO")
.font(.system(size: 9, weight: .semibold)) .font(.system(size: 9, weight: .semibold))
@@ -73,37 +94,25 @@ struct HistoryRowView: View {
.stroke(AppTheme.inkQuaternary, lineWidth: 0.75) .stroke(AppTheme.inkQuaternary, lineWidth: 0.75)
) )
} }
Spacer()
Text(entry.date, style: .time)
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkTertiary)
} }
HStack(spacing: 8) { // Stat line
Text("\(entry.uploadedCount) uploaded") statLine
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
if entry.skippedCount > 0 {
dot
Text("\(entry.skippedCount) skipped")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
if entry.failedCount > 0 {
dot
Text("\(entry.failedCount) errors")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.destructive)
}
}
// Secondary info: host · duration · size
HStack(spacing: 6) {
Text(entry.nasHost) Text(entry.nasHost)
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkQuaternary) .foregroundStyle(AppTheme.inkQuaternary)
midDot
Text(formatDuration(entry.durationSeconds))
.foregroundStyle(AppTheme.inkQuaternary)
if entry.totalBytes > 0 {
midDot
Text(formatBytes(entry.totalBytes))
.foregroundStyle(AppTheme.inkQuaternary)
}
}
.font(.system(size: 11, weight: .regular))
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -112,13 +121,48 @@ struct HistoryRowView: View {
Rectangle() Rectangle()
.fill(AppTheme.separator) .fill(AppTheme.separator)
.frame(height: 0.5) .frame(height: 0.5)
.padding(.leading, 37) .padding(.leading, 36)
} }
} }
private var dot: some View { @ViewBuilder
private var statLine: some View {
HStack(spacing: 6) {
Text("\(entry.uploadedCount) uploaded")
.foregroundStyle(AppTheme.inkSecondary)
if entry.skippedCount > 0 {
midDot
Text("\(entry.skippedCount) skipped")
.foregroundStyle(AppTheme.inkTertiary)
}
if hasErrors {
midDot
Text("\(entry.failedCount) errors")
.foregroundStyle(AppTheme.destructive)
}
}
.font(AppTheme.caption())
}
private var midDot: some View {
Circle() Circle()
.fill(AppTheme.inkQuaternary) .fill(AppTheme.inkQuaternary)
.frame(width: 3, height: 3) .frame(width: 3, height: 3)
} }
private func formatDuration(_ s: Double) -> String {
let m = Int(s) / 60
let sec = Int(s) % 60
return m > 0 ? "\(m)m \(sec)s" : "\(sec)s"
}
private func formatBytes(_ bytes: Int64) -> String {
let gb = Double(bytes) / 1_073_741_824
if gb >= 1 { return String(format: "%.1f GB", gb) }
let mb = Double(bytes) / 1_048_576
if mb >= 1 { return String(format: "%.0f MB", mb) }
return String(format: "%.0f KB", Double(bytes) / 1024)
}
} }

View File

@@ -1,14 +1,13 @@
import SwiftUI import SwiftUI
struct LoginView: View { struct LoginView: View {
@EnvironmentObject var store: ConnectionStore
@StateObject private var vm = LoginViewModel() @StateObject private var vm = LoginViewModel()
@State private var navigateToDashboard = false
@State private var logoAppeared = false @State private var logoAppeared = false
var body: some View { var body: some View {
NavigationStack {
ZStack { ZStack {
Color.white.ignoresSafeArea() AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
@@ -26,25 +25,14 @@ struct LoginView: View {
Spacer().frame(height: 32) Spacer().frame(height: 32)
// Device + account // Title + account
VStack(spacing: 6) { VStack(spacing: 5) {
Text("Forge") Text("Kisani")
.font(.system(size: 22, weight: .semibold)) .font(.system(size: 22, weight: .semibold))
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
Text("Sign in to continue")
HStack(spacing: 8) {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 28, height: 28)
Text("AB")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(AppTheme.inkSecondary)
}
Text("Albert")
.font(AppTheme.caption()) .font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkTertiary)
}
} }
.opacity(logoAppeared ? 1 : 0) .opacity(logoAppeared ? 1 : 0)
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared) .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared)
@@ -56,7 +44,7 @@ struct LoginView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
if vm.isAuthenticating { if vm.isAuthenticating {
ProgressView() ProgressView()
.tint(.white) .tint(AppTheme.inkInverse)
.scaleEffect(0.8) .scaleEffect(0.8)
} else { } else {
Image(systemName: "faceid") Image(systemName: "faceid")
@@ -90,21 +78,19 @@ struct LoginView: View {
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared) .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared)
} }
} }
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
.sheet(isPresented: $vm.showMoreSheet) { .sheet(isPresented: $vm.showMoreSheet) {
MoreOptionsSheet(isPresented: $vm.showMoreSheet) { MoreOptionsSheet(isPresented: $vm.showMoreSheet) {
vm.showMoreSheet = false vm.showMoreSheet = false
navigateToDashboard = true store.isSessionActive = true
} }
.presentationDetents([.height(200)]) .presentationDetents([.height(200)])
.modifier(SheetCornerRadius(20)) .modifier(SheetCornerRadius(20))
} }
.onAppear { .onAppear {
vm.onAuthenticated = { navigateToDashboard = true } vm.onAuthenticated = { store.isSessionActive = true }
logoAppeared = true logoAppeared = true
} }
} }
}
} }
struct MoreOptionsSheet: View { struct MoreOptionsSheet: View {
@@ -138,7 +124,7 @@ struct MoreOptionsSheet: View {
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 16) .padding(.bottom, 16)
} }
.background(Color.white) .background(AppTheme.background)
} }
private func sheetRow( private func sheetRow(

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import LocalAuthentication import LocalAuthentication
import os.log
@MainActor @MainActor
final class LoginViewModel: ObservableObject { final class LoginViewModel: ObservableObject {
@@ -9,26 +10,34 @@ final class LoginViewModel: ObservableObject {
var onAuthenticated: (() -> Void)? var onAuthenticated: (() -> Void)?
private let logger = Logger(subsystem: "com.albert.nasbackup", category: "Login")
func authenticateWithFaceID() { func authenticateWithFaceID() {
logger.info("Face ID authentication started")
isAuthenticating = true isAuthenticating = true
authError = nil authError = nil
let context = LAContext() let context = LAContext()
var error: NSError? var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
let reason = error?.localizedDescription ?? "unknown"
logger.error("Face ID not available: \(reason, privacy: .public)")
authError = "Face ID not available" authError = "Face ID not available"
isAuthenticating = false isAuthenticating = false
return return
} }
context.evaluatePolicy( context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics, .deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Sign in to NASBackup" localizedReason: "Sign in to Kisani"
) { [weak self] success, evalError in ) { [weak self] success, evalError in
Task { @MainActor in Task { @MainActor in
self?.isAuthenticating = false self?.isAuthenticating = false
if success { if success {
self?.logger.info("Face ID authentication succeeded — advancing session")
self?.onAuthenticated?() self?.onAuthenticated?()
} else { } else {
self?.authError = evalError?.localizedDescription ?? "Authentication failed" let reason = evalError?.localizedDescription ?? "Authentication failed"
self?.logger.error("Face ID authentication failed: \(reason, privacy: .public)")
self?.authError = reason
} }
} }
} }

View File

@@ -0,0 +1,151 @@
import SwiftUI
struct FolderSetupView: View {
@EnvironmentObject var store: ConnectionStore
@State private var showBrowser = false
@State private var selectedPath: String = "/"
@State private var appeared = false
private var connection: NASConnection? { store.savedConnection }
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) {
// Step indicator
stepIndicator
.padding(.top, 20)
.padding(.bottom, 36)
.opacity(appeared ? 1 : 0)
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.05), value: appeared)
// Icon
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 80, height: 80)
Image(systemName: "folder.badge.plus")
.font(.system(size: 32, weight: .light))
.foregroundStyle(AppTheme.ink)
}
.scaleEffect(appeared ? 1 : 0.85)
.opacity(appeared ? 1 : 0)
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: appeared)
Spacer().frame(height: 28)
// Title + description
VStack(spacing: 8) {
Text("Choose a destination")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(AppTheme.ink)
Text("Select the folder on your NAS where photos will be backed up.")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.opacity(appeared ? 1 : 0)
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.15), value: appeared)
Spacer().frame(height: 32)
// Connection chip
if let conn = connection {
connectionChip(conn)
.opacity(appeared ? 1 : 0)
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.2), value: appeared)
}
Spacer().frame(height: 16)
// Selected path display / browse button
Button(action: { showBrowser = true }) {
HStack(spacing: 12) {
Image(systemName: selectedPath == "/" ? "folder" : "folder.fill")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(selectedPath == "/" ? AppTheme.inkQuaternary : AppTheme.interactive)
.frame(width: 20)
Text(selectedPath == "/" ? "Tap to browse folders" : selectedPath)
.font(AppTheme.body())
.foregroundStyle(selectedPath == "/" ? AppTheme.inkTertiary : AppTheme.ink)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
.buttonStyle(ScaleButtonStyle())
.padding(.horizontal, AppTheme.hPad)
.opacity(appeared ? 1 : 0)
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.25), value: appeared)
Spacer()
// Confirm button
Button(action: confirmFolder) {
Text("Save This Folder")
}
.buttonStyle(PrimaryButtonStyle())
.padding(.horizontal, AppTheme.hPad)
.disabled(selectedPath == "/")
.opacity(selectedPath == "/" ? 0.4 : 1)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: selectedPath == "/")
.opacity(appeared ? 1 : 0)
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.3), value: appeared)
Spacer().frame(height: 48)
}
}
.sheet(isPresented: $showBrowser) {
if let conn = connection {
BrowseView(connection: conn, selectedPath: $selectedPath)
}
}
.onAppear { appeared = true }
}
private var stepIndicator: some View {
HStack(spacing: 6) {
ForEach(1...3, id: \.self) { step in
Capsule()
.fill(step == 2 ? AppTheme.ink : AppTheme.inkQuaternary)
.frame(width: step == 2 ? 20 : 6, height: 6)
}
}
}
private func connectionChip(_ conn: NASConnection) -> some View {
HStack(spacing: 6) {
Circle()
.fill(AppTheme.positive)
.frame(width: 6, height: 6)
Text(conn.host)
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkSecondary)
Text("·")
.foregroundStyle(AppTheme.inkQuaternary)
Text(conn.nasProtocol.rawValue)
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(AppTheme.surfaceSunken)
.clipShape(Capsule(style: .continuous))
}
private func confirmFolder() {
guard selectedPath != "/", var conn = store.savedConnection else { return }
conn.remotePath = selectedPath
store.savedConnection = conn
}
}

View File

@@ -0,0 +1,129 @@
import SwiftUI
import Photos
struct PhotoPermissionView: View {
@EnvironmentObject var store: ConnectionStore
@State private var appeared = false
@State private var isRequesting = false
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) {
// Step indicator
stepIndicator
.padding(.top, 20)
.padding(.bottom, 36)
.opacity(appeared ? 1 : 0)
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.05), value: appeared)
Spacer()
// Icon
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 96, height: 96)
Image(systemName: "photo.stack")
.font(.system(size: 38, weight: .light))
.foregroundStyle(AppTheme.ink)
}
.scaleEffect(appeared ? 1 : 0.85)
.opacity(appeared ? 1 : 0)
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: appeared)
Spacer().frame(height: 32)
// Title + description
VStack(spacing: 10) {
Text("Access your photos")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppTheme.ink)
Text("Kisani needs read access to your photo library to find and back up your photos and videos to the NAS.")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
// Detail chips
HStack(spacing: 8) {
detailChip(icon: "eye.slash", label: "Read-only")
detailChip(icon: "lock.shield", label: "Stays on-device")
detailChip(icon: "icloud.slash", label: "No cloud upload")
}
.padding(.top, 8)
}
.opacity(appeared ? 1 : 0)
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.18), value: appeared)
Spacer()
// Buttons
VStack(spacing: 12) {
Button(action: requestAccess) {
Group {
if isRequesting {
ProgressView().tint(AppTheme.inkInverse)
} else {
Text("Allow Photo Access")
}
}
}
.buttonStyle(PrimaryButtonStyle())
.disabled(isRequesting)
Button(action: skipAndContinue) {
Text("Not Now")
}
.buttonStyle(GhostButtonStyle())
}
.padding(.horizontal, AppTheme.hPad)
.opacity(appeared ? 1 : 0)
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.28), value: appeared)
Spacer().frame(height: 48)
}
}
.onAppear { appeared = true }
}
private var stepIndicator: some View {
HStack(spacing: 6) {
ForEach(1...3, id: \.self) { step in
Capsule()
.fill(step == 3 ? AppTheme.ink : AppTheme.inkQuaternary)
.frame(width: step == 3 ? 20 : 6, height: 6)
}
}
}
private func detailChip(icon: String, label: String) -> some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: 10, weight: .medium))
Text(label)
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(AppTheme.inkSecondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(AppTheme.surfaceSunken)
.clipShape(Capsule(style: .continuous))
}
private func requestAccess() {
isRequesting = true
PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in
DispatchQueue.main.async {
isRequesting = false
store.onboardingPhotoShown = true
}
}
}
private func skipAndContinue() {
store.onboardingPhotoShown = true
}
}

View File

@@ -131,7 +131,7 @@ struct LANTriggerSection: View {
.padding(.horizontal, AppTheme.cardPad) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12) .padding(.vertical, 12)
} }
.background(Color.white) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
} }

View File

@@ -6,10 +6,11 @@ struct SettingsView: View {
var body: some View { var body: some View {
ZStack { ZStack {
Color.white.ignoresSafeArea() AppTheme.background.ignoresSafeArea()
ScrollView { ScrollView {
VStack(spacing: AppTheme.sectionGap) { VStack(spacing: AppTheme.sectionGap) {
appearanceSection
LANTriggerSection() LANTriggerSection()
mediaSection mediaSection
quietHoursSection quietHoursSection
@@ -26,6 +27,45 @@ struct SettingsView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
// MARK: Appearance
private var appearanceSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("APPEARANCE")
HStack(spacing: 0) {
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
store.appearanceMode = mode
}
} label: {
let selected = store.appearanceMode == mode
HStack(spacing: 5) {
Image(systemName: mode.icon)
.font(.system(size: 12, weight: selected ? .semibold : .regular))
Text(mode.label)
.font(.system(size: 12, weight: selected ? .semibold : .regular))
}
.foregroundStyle(selected ? AppTheme.ink : AppTheme.inkTertiary)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(selected ? AppTheme.surfaceRaised : Color.clear)
.shadow(color: selected ? .black.opacity(0.08) : .clear,
radius: 4, x: 0, y: 1)
)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(3)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
}
}
// MARK: Media // MARK: Media
private var mediaSection: some View { private var mediaSection: some View {
@@ -49,7 +89,7 @@ struct SettingsView: View {
subtitle: "Skip files already on NAS", subtitle: "Skip files already on NAS",
isOn: $store.backupFilter.newFilesOnly) isOn: $store.backupFilter.newFilesOnly)
} }
.background(Color.white) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
} }
@@ -78,7 +118,7 @@ struct SettingsView: View {
subtitle: "Preserve battery on large libraries", subtitle: "Preserve battery on large libraries",
isOn: $store.chargingOnlyMode) isOn: $store.chargingOnlyMode)
} }
.background(Color.white) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.quietHoursEnabled) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.quietHoursEnabled)
@@ -135,7 +175,7 @@ struct SettingsView: View {
.padding(.vertical, 10) .padding(.vertical, 10)
} }
} }
.background(Color.white) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.isOnCellular) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.isOnCellular)
@@ -206,13 +246,15 @@ struct SettingsView: View {
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
} }
.buttonStyle(ScaleButtonStyle()) .buttonStyle(ScaleButtonStyle())
.accessibilityLabel("Open Tailscale app")
.accessibilityHint("Opens the Tailscale app to connect the VPN")
} }
} }
.padding(.horizontal, AppTheme.cardPad) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12) .padding(.vertical, 12)
} }
} }
.background(Color.white) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.useTailscaleWhenRemote) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.useTailscaleWhenRemote)
@@ -233,7 +275,7 @@ struct SettingsView: View {
hairline hairline
notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On") notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On")
} }
.background(Color.white) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
} }

View File

@@ -7,26 +7,37 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
10062AA91BC883AE597443BD /* URL+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277DF2E5F4E566C869EC29F3 /* URL+.swift */; };
13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */; };
30693CB8EE711577978EF88C /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */; };
36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */; }; 36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */; };
37D808079BDEF32D04847D04 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09160B7B7A06BD900D0D87A /* MainTabView.swift */; }; 37D808079BDEF32D04847D04 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09160B7B7A06BD900D0D87A /* MainTabView.swift */; };
4148416D31C8AC65FEE6394F /* PHAsset+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */; };
45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8F24C7DD7A503A0D561F3FB /* LoginView.swift */; }; 45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8F24C7DD7A503A0D561F3FB /* LoginView.swift */; };
512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */; }; 512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */; };
57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */; }; 57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */; };
6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EEBCD4A838173E557DC3EFC /* HistoryView.swift */; }; 6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EEBCD4A838173E557DC3EFC /* HistoryView.swift */; };
664E9F44D037A8B41D5A7670 /* MockNASService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BACE1D31086231C97DAD884D /* MockNASService.swift */; };
685E8F1B44AE5652E93C58CE /* NASProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */; };
6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */; }; 6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */; };
77A3FF7E2E4F506E7F9E5CCD /* FolderSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */; };
7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */; }; 7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */; };
7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */; }; 7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */; };
7FB20CB93FECB2F9CBE67161 /* ConnectViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50191596BF6E84CFAE7904D3 /* ConnectViewModelTests.swift */; };
834A766E13EF297273FFEE21 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */; }; 834A766E13EF297273FFEE21 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */; };
84E3EE64F74D00104BF8694B /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */; }; 84E3EE64F74D00104BF8694B /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */; };
8657A3E4C5E377FE34840E9A /* BackupEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824FADB40473BFC36FE91F0A /* BackupEngine.swift */; }; 8657A3E4C5E377FE34840E9A /* BackupEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824FADB40473BFC36FE91F0A /* BackupEngine.swift */; };
88879238876B1E5A1BE15DF3 /* SMBClient in Frameworks */ = {isa = PBXBuildFile; productRef = 9CEDCE7A5B4B267C66F39ABE /* SMBClient */; }; 88879238876B1E5A1BE15DF3 /* SMBClient in Frameworks */ = {isa = PBXBuildFile; productRef = 9CEDCE7A5B4B267C66F39ABE /* SMBClient */; };
8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */; }; 8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */; };
8EB77E535C82274276C1E328 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */; };
9444B0EB5AD2936DA837320F /* BackupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03F0ED1A914F3392BFE5E4A /* BackupError.swift */; }; 9444B0EB5AD2936DA837320F /* BackupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03F0ED1A914F3392BFE5E4A /* BackupError.swift */; };
96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */; };
9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBD92D10D70B957192E271F /* SMBService.swift */; }; 9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBD92D10D70B957192E271F /* SMBService.swift */; };
9A2F717F6A8F4AA85FA636FC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 7BEB5BEBEB935BC8E2182A7B /* Citadel */; }; 9A2F717F6A8F4AA85FA636FC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 7BEB5BEBEB935BC8E2182A7B /* Citadel */; };
9CF57AC05F30A51B6B5269DC /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B33F755DA5850D1FE3F695B /* AppTheme.swift */; }; 9CF57AC05F30A51B6B5269DC /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B33F755DA5850D1FE3F695B /* AppTheme.swift */; };
A6276840D40211EB446293A9 /* BackupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */; }; A6276840D40211EB446293A9 /* BackupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */; };
A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */; }; A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */; };
A79F538803790524284BCAD6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */; };
B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8845003C87961174302AC990 /* ToggleRow.swift */; }; B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8845003C87961174302AC990 /* ToggleRow.swift */; };
B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C955FEF6710E3667C7A73A02 /* ProgressRing.swift */; }; B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C955FEF6710E3667C7A73A02 /* ProgressRing.swift */; };
B7DBF1420838AD3397A280B5 /* BackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55EA454B783C2AEDABFB00A7 /* BackupView.swift */; }; B7DBF1420838AD3397A280B5 /* BackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55EA454B783C2AEDABFB00A7 /* BackupView.swift */; };
@@ -34,8 +45,11 @@
C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */; }; C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */; };
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */; }; C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */; };
CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */; }; CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */; };
CE84BC18F045AB65CCA30BB4 /* LANMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */; };
DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */; }; DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */; };
DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE318B60576B11E80862664D /* ConnectView.swift */; }; DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE318B60576B11E80862664D /* ConnectView.swift */; };
DFD2252BDCE9EE7ABAE63BF9 /* BackupEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336D155A4D1C895215A4FFF2 /* BackupEngineTests.swift */; };
E24CEE0D51131D991038205D /* PhotoPermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AC72ED4015671C51066AC3E /* PhotoPermissionView.swift */; };
E830DB3078A8873382A77B63 /* BackupResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AEFE4959C08B94AE9584A11 /* BackupResult.swift */; }; E830DB3078A8873382A77B63 /* BackupResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AEFE4959C08B94AE9584A11 /* BackupResult.swift */; };
ED0D2A1D05B5898BCCE9EA0A /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */; }; ED0D2A1D05B5898BCCE9EA0A /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */; };
F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87319355D902007618A91AE /* ConnectionStore.swift */; }; F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87319355D902007618A91AE /* ConnectionStore.swift */; };
@@ -43,22 +57,41 @@
FDC18499CC21A58C2F6F1BDB /* BackupJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E762898E351192399FC739 /* BackupJob.swift */; }; FDC18499CC21A58C2F6F1BDB /* BackupJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E762898E351192399FC739 /* BackupJob.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
2F6BE82D286218B2DC8D8B98 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 82D1639F1555985B1BEE4547 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 32316B985B8906FE4CB97730;
remoteInfo = NASBackup;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASTransferProtocol.swift; sourceTree = "<group>"; }; 055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASTransferProtocol.swift; sourceTree = "<group>"; };
0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
277DF2E5F4E566C869EC29F3 /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = "<group>"; };
2B33F755DA5850D1FE3F695B /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; }; 2B33F755DA5850D1FE3F695B /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASConnection.swift; sourceTree = "<group>"; }; 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASConnection.swift; sourceTree = "<group>"; };
336D155A4D1C895215A4FFF2 /* BackupEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngineTests.swift; sourceTree = "<group>"; };
3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderSetupView.swift; sourceTree = "<group>"; };
46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = "<group>"; }; 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = "<group>"; };
47E762898E351192399FC739 /* BackupJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupJob.swift; sourceTree = "<group>"; }; 47E762898E351192399FC739 /* BackupJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupJob.swift; sourceTree = "<group>"; };
4AEFE4959C08B94AE9584A11 /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; }; 4AEFE4959C08B94AE9584A11 /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
4B67584BB6D705BCB3D40192 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; }; 4B67584BB6D705BCB3D40192 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = "<group>"; }; 4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = "<group>"; };
4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = "<group>"; };
50191596BF6E84CFAE7904D3 /* ConnectViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModelTests.swift; sourceTree = "<group>"; };
55EA454B783C2AEDABFB00A7 /* BackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupView.swift; sourceTree = "<group>"; }; 55EA454B783C2AEDABFB00A7 /* BackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupView.swift; sourceTree = "<group>"; };
5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = "<group>"; }; 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = "<group>"; };
5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; }; 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; };
6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; }; 6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = "<group>"; }; 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = "<group>"; };
7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASProtocolTests.swift; sourceTree = "<group>"; };
7AC72ED4015671C51066AC3E /* PhotoPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPermissionView.swift; sourceTree = "<group>"; };
824FADB40473BFC36FE91F0A /* BackupEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngine.swift; sourceTree = "<group>"; }; 824FADB40473BFC36FE91F0A /* BackupEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngine.swift; sourceTree = "<group>"; };
83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderRow.swift; sourceTree = "<group>"; }; 83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderRow.swift; sourceTree = "<group>"; };
86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHAsset+.swift"; sourceTree = "<group>"; };
8845003C87961174302AC990 /* ToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRow.swift; sourceTree = "<group>"; }; 8845003C87961174302AC990 /* ToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRow.swift; sourceTree = "<group>"; };
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
91197DD51221A3277AE318E2 /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; }; 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
@@ -66,18 +99,24 @@
A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; }; A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = "<group>"; }; AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = "<group>"; };
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<group>"; }; B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<group>"; };
B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UGreenCompatibilityBadge.swift; sourceTree = "<group>"; };
BACE1D31086231C97DAD884D /* MockNASService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNASService.swift; sourceTree = "<group>"; };
C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = "<group>"; }; C955FEF6710E3667C7A73A02 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = "<group>"; };
CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; }; CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; };
D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; }; D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; };
D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; }; D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
DEBD92D10D70B957192E271F /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = "<group>"; }; DEBD92D10D70B957192E271F /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = "<group>"; };
E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = NASBackupTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EE318B60576B11E80862664D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = "<group>"; }; EE318B60576B11E80862664D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = "<group>"; };
F03F0ED1A914F3392BFE5E4A /* BackupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupError.swift; sourceTree = "<group>"; }; F03F0ED1A914F3392BFE5E4A /* BackupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupError.swift; sourceTree = "<group>"; };
F09160B7B7A06BD900D0D87A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; }; F09160B7B7A06BD900D0D87A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitorTests.swift; sourceTree = "<group>"; };
F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; }; F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; };
F5CE897758E9CD5BB8B80F63 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; F5CE897758E9CD5BB8B80F63 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -102,6 +141,7 @@
1AD5A741A7466C3510901C60 /* Connect */, 1AD5A741A7466C3510901C60 /* Connect */,
88D0BD5235374F2A264A0C96 /* History */, 88D0BD5235374F2A264A0C96 /* History */,
FDF5327105186A6456CF0B08 /* Login */, FDF5327105186A6456CF0B08 /* Login */,
611BB6A8B01D82432C0A32EC /* Onboarding */,
147B04CA2B7A7723CCD13E93 /* Settings */, 147B04CA2B7A7723CCD13E93 /* Settings */,
); );
path = Features; path = Features;
@@ -115,6 +155,7 @@
0A801C860EB4C4B22FD4D073 /* Features */, 0A801C860EB4C4B22FD4D073 /* Features */,
ADFBF8F90DF8352BD2833A66 /* Services */, ADFBF8F90DF8352BD2833A66 /* Services */,
31C8DD8F9077E2B6C67A99BA /* Shared */, 31C8DD8F9077E2B6C67A99BA /* Shared */,
D7CC50C0264BFFC23DA2C868 /* Tests */,
8308FE572D56B8045F17F6A0 /* Products */, 8308FE572D56B8045F17F6A0 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -123,6 +164,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D87319355D902007618A91AE /* ConnectionStore.swift */, D87319355D902007618A91AE /* ConnectionStore.swift */,
4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */,
D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */,
); );
path = Persistence; path = Persistence;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -136,6 +179,16 @@
path = Settings; path = Settings;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
185383972A68402ADF89D64C /* Extensions */ = {
isa = PBXGroup;
children = (
F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */,
86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */,
277DF2E5F4E566C869EC29F3 /* URL+.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
1AD5A741A7466C3510901C60 /* Connect */ = { 1AD5A741A7466C3510901C60 /* Connect */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -149,6 +202,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3E7F8DE77B7CDC7009B54D63 /* Components */, 3E7F8DE77B7CDC7009B54D63 /* Components */,
185383972A68402ADF89D64C /* Extensions */,
0B27021136C1E85D5B8CFF97 /* Persistence */, 0B27021136C1E85D5B8CFF97 /* Persistence */,
C874CB05A914EE933FCFD5C2 /* Theme */, C874CB05A914EE933FCFD5C2 /* Theme */,
); );
@@ -162,10 +216,20 @@
83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */, 83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */,
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */, C955FEF6710E3667C7A73A02 /* ProgressRing.swift */,
8845003C87961174302AC990 /* ToggleRow.swift */, 8845003C87961174302AC990 /* ToggleRow.swift */,
B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
611BB6A8B01D82432C0A32EC /* Onboarding */ = {
isa = PBXGroup;
children = (
3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */,
7AC72ED4015671C51066AC3E /* PhotoPermissionView.swift */,
);
path = Onboarding;
sourceTree = "<group>";
};
803154EA96E2431367806BAF /* Backup */ = { 803154EA96E2431367806BAF /* Backup */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -188,6 +252,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C755BFDD42D46A3FB490A4BB /* NASBackup.app */, C755BFDD42D46A3FB490A4BB /* NASBackup.app */,
E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -214,6 +279,7 @@
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */, 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */,
F5CE897758E9CD5BB8B80F63 /* Info.plist */, F5CE897758E9CD5BB8B80F63 /* Info.plist */,
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */, B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */,
0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */,
F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */, F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */,
); );
path = App; path = App;
@@ -258,6 +324,18 @@
path = Theme; path = Theme;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D7CC50C0264BFFC23DA2C868 /* Tests */ = {
isa = PBXGroup;
children = (
336D155A4D1C895215A4FFF2 /* BackupEngineTests.swift */,
50191596BF6E84CFAE7904D3 /* ConnectViewModelTests.swift */,
F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */,
BACE1D31086231C97DAD884D /* MockNASService.swift */,
7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */,
);
path = Tests;
sourceTree = "<group>";
};
F75092ACBEF36F32816506F7 /* Models */ = { F75092ACBEF36F32816506F7 /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -285,6 +363,7 @@
buildConfigurationList = F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */; buildConfigurationList = F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */;
buildPhases = ( buildPhases = (
E6D3CCCB60977A7DABFE7F19 /* Sources */, E6D3CCCB60977A7DABFE7F19 /* Sources */,
6DB4B780F7FFCAAC2B48E78F /* Resources */,
BC332B29F6F9E065FF85AED8 /* Frameworks */, BC332B29F6F9E065FF85AED8 /* Frameworks */,
); );
buildRules = ( buildRules = (
@@ -300,6 +379,24 @@
productReference = C755BFDD42D46A3FB490A4BB /* NASBackup.app */; productReference = C755BFDD42D46A3FB490A4BB /* NASBackup.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
88E89D71F1D8FA18A6A64BBF /* NASBackupTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3B812FFDA55D20BD7BC79D06 /* Build configuration list for PBXNativeTarget "NASBackupTests" */;
buildPhases = (
E2CED6E8ED76A743BB9C913F /* Sources */,
);
buildRules = (
);
dependencies = (
EA438E47054B868AA6F6727D /* PBXTargetDependency */,
);
name = NASBackupTests;
packageProductDependencies = (
);
productName = NASBackupTests;
productReference = E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -313,6 +410,10 @@
DevelopmentTeam = ""; DevelopmentTeam = "";
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
88E89D71F1D8FA18A6A64BBF = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
}; };
}; };
buildConfigurationList = 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "NASBackup" */; buildConfigurationList = 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "NASBackup" */;
@@ -334,11 +435,35 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
32316B985B8906FE4CB97730 /* NASBackup */, 32316B985B8906FE4CB97730 /* NASBackup */,
88E89D71F1D8FA18A6A64BBF /* NASBackupTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
6DB4B780F7FFCAAC2B48E78F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A79F538803790524284BCAD6 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
E2CED6E8ED76A743BB9C913F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DFD2252BDCE9EE7ABAE63BF9 /* BackupEngineTests.swift in Sources */,
7FB20CB93FECB2F9CBE67161 /* ConnectViewModelTests.swift in Sources */,
CE84BC18F045AB65CCA30BB4 /* LANMonitorTests.swift in Sources */,
664E9F44D037A8B41D5A7670 /* MockNASService.swift in Sources */,
685E8F1B44AE5652E93C58CE /* NASProtocolTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
E6D3CCCB60977A7DABFE7F19 /* Sources */ = { E6D3CCCB60977A7DABFE7F19 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -357,8 +482,11 @@
DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */, DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */,
C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */, C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */,
F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */, F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */,
30693CB8EE711577978EF88C /* Date+.swift in Sources */,
6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */, 6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */,
77A3FF7E2E4F506E7F9E5CCD /* FolderSetupView.swift in Sources */,
6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */, 6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */,
8EB77E535C82274276C1E328 /* KeychainStore.swift in Sources */,
CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */, CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */,
7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */, 7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */,
45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */, 45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */,
@@ -367,20 +495,50 @@
7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */, 7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */,
512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */, 512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */,
8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */, 8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */,
4148416D31C8AC65FEE6394F /* PHAsset+.swift in Sources */,
DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */, DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */,
57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */, 57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */,
E24CEE0D51131D991038205D /* PhotoPermissionView.swift in Sources */,
B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */, B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */,
BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */, BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */,
9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */, 9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */,
13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */,
A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */, A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */,
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */, C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */,
B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */, B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */,
96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */,
10062AA91BC883AE597443BD /* URL+.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
EA438E47054B868AA6F6727D /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 32316B985B8906FE4CB97730 /* NASBackup */;
targetProxy = 2F6BE82D286218B2DC8D8B98 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
53DABEDF383DBBE47A3A3FD7 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NASBackup.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/NASBackup";
};
name = Release;
};
5496D0713D70962E349CB0C8 /* Debug */ = { 5496D0713D70962E349CB0C8 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -397,6 +555,23 @@
}; };
name = Debug; name = Debug;
}; };
7215384C2378F778AC60259A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NASBackup.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/NASBackup";
};
name = Debug;
};
AAEAFE599F3F20DB264D4528 /* Debug */ = { AAEAFE599F3F20DB264D4528 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -554,6 +729,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
3B812FFDA55D20BD7BC79D06 /* Build configuration list for PBXNativeTarget "NASBackupTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7215384C2378F778AC60259A /* Debug */,
53DABEDF383DBBE47A3A3FD7 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */ = { F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (

View File

@@ -1,5 +1,8 @@
import Foundation import Foundation
import UserNotifications import UserNotifications
import os.log
private let logger = Logger(subsystem: "com.albert.nasbackup", category: "BackupEngine")
@MainActor @MainActor
final class BackupEngine: ObservableObject { final class BackupEngine: ObservableObject {
@@ -7,11 +10,30 @@ final class BackupEngine: ObservableObject {
@Published private(set) var job: BackupJob = BackupJob() @Published private(set) var job: BackupJob = BackupJob()
private let photoService: PhotoLibraryProtocol = PhotoLibraryService() private let photoService: PhotoLibraryProtocol
private let transferFactory: (NASProtocol) -> any NASTransferProtocol
private var activeTransfer: (any NASTransferProtocol)? private var activeTransfer: (any NASTransferProtocol)?
private var isCancelled = false private var isCancelled = false
private init() {} private init(
photos: PhotoLibraryProtocol = PhotoLibraryService(),
transferFactory: @escaping (NASProtocol) -> any NASTransferProtocol = { proto in
switch proto {
case .smb: return SMBService()
case .sftp: return SFTPService()
}
}
) {
self.photoService = photos
self.transferFactory = transferFactory
}
static func testInstance(
transfer: any NASTransferProtocol,
photos: PhotoLibraryProtocol
) -> BackupEngine {
BackupEngine(photos: photos, transferFactory: { _ in transfer })
}
func run( func run(
connection: NASConnection, connection: NASConnection,
@@ -36,11 +58,7 @@ final class BackupEngine: ObservableObject {
let assets = photoService.fetchAssets(filter: filter) let assets = photoService.fetchAssets(filter: filter)
// 2. Connect to NAS // 2. Connect to NAS
let transfer: any NASTransferProtocol let transfer = transferFactory(connection.nasProtocol)
switch connection.nasProtocol {
case .smb: transfer = SMBService()
case .sftp: transfer = SFTPService()
}
try await transfer.connect( try await transfer.connect(
to: host, to: host,
@@ -102,6 +120,7 @@ final class BackupEngine: ObservableObject {
job.fileCompleted(skipped: false) job.fileCompleted(skipped: false)
uploaded += 1 uploaded += 1
} catch { } catch {
logger.error("Upload failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)")
job.fileFailed() job.fileFailed()
failed += 1 failed += 1
} }

View File

@@ -44,9 +44,9 @@ final class PhotoLibraryService: PhotoLibraryProtocol {
let result = PHAsset.fetchAssets(with: options) let result = PHAsset.fetchAssets(with: options)
var assets: [PhotoAsset] = [] var assets: [PhotoAsset] = []
result.enumerateObjects { asset, _, _ in result.enumerateObjects { asset, _, _ in
autoreleasepool {
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot) let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
let resources = PHAssetResource.assetResources(for: asset) let resources = PHAssetResource.assetResources(for: asset)
// RAW files are identified by an alternate photo resource (RAW+JPEG pairs)
let isRAW = resources.contains { $0.type == .alternatePhoto } let isRAW = resources.contains { $0.type == .alternatePhoto }
if isRAW && !filter.includeRAW { return } if isRAW && !filter.includeRAW { return }
@@ -64,6 +64,7 @@ final class PhotoLibraryService: PhotoLibraryProtocol {
isRAW: isRAW isRAW: isRAW
)) ))
} }
}
return assets return assets
} }

View File

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

View File

@@ -29,6 +29,8 @@ struct ToggleRow: View {
Toggle("", isOn: $isOn) Toggle("", isOn: $isOn)
.labelsHidden() .labelsHidden()
.tint(AppTheme.ink) .tint(AppTheme.ink)
.accessibilityLabel(title)
.accessibilityValue(isOn ? "On" : "Off")
} }
.padding(.horizontal, AppTheme.cardPad) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12) .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() static let shared = ConnectionStore()
@Published var savedConnection: NASConnection? { @Published var savedConnection: NASConnection? {
didSet { saveUD(savedConnection, key: "savedConnection") } didSet {
if let conn = savedConnection { SavedConnections.save(conn) }
else { SavedConnections.delete() }
}
} }
@Published var backupFilter: BackupFilter { @Published var backupFilter: BackupFilter {
didSet { saveUD(backupFilter, key: "backupFilter") } didSet { saveUD(backupFilter, key: "backupFilter") }
@@ -42,6 +45,19 @@ final class ConnectionStore: ObservableObject {
didSet { UserDefaults.standard.set(quietHoursEnd, forKey: "quietHoursEnd") } 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 // Cellular & remote access
@Published var allowCellularBackup: Bool { @Published var allowCellularBackup: Bool {
didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") } didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") }
@@ -56,7 +72,7 @@ final class ConnectionStore: ObservableObject {
@Published private(set) var historyEntries: [BackupHistoryEntry] = [] @Published private(set) var historyEntries: [BackupHistoryEntry] = []
private init() { private init() {
savedConnection = ud(NASConnection.self, key: "savedConnection") savedConnection = SavedConnections.load()
backupFilter = ud(BackupFilter.self, key: "backupFilter") ?? BackupFilter() backupFilter = ud(BackupFilter.self, key: "backupFilter") ?? BackupFilter()
trustedSSIDs = UserDefaults.standard.stringArray(forKey: "trustedSSIDs") ?? [] trustedSSIDs = UserDefaults.standard.stringArray(forKey: "trustedSSIDs") ?? []
lanTriggerEnabled = UserDefaults.standard.object(forKey: "lanTriggerEnabled") as? Bool ?? true 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 quietHoursEnabled = UserDefaults.standard.object(forKey: "quietHoursEnabled") as? Bool ?? false
quietHoursStart = UserDefaults.standard.object(forKey: "quietHoursStart") as? Int ?? 23 quietHoursStart = UserDefaults.standard.object(forKey: "quietHoursStart") as? Int ?? 23
quietHoursEnd = UserDefaults.standard.object(forKey: "quietHoursEnd") as? Int ?? 7 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 allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false
useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false
tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? "" 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,16 +1,74 @@
import SwiftUI 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 { enum AppTheme {
// MARK: Surfaces // MARK: Surfaces
static let background = Color.white static let background = Color(UIColor.systemBackground)
static let surfaceRaised = Color.white static let surfaceRaised = Color(UIColor.systemBackground)
static let surfaceSunken = Color(hex: "#F5F5F5") static let surfaceSunken = Color(UIColor { t in
t.userInterfaceStyle == .dark
? UIColor(white: 0.11, alpha: 1)
: UIColor(white: 0.961, alpha: 1)
})
// MARK: Ink // MARK: Ink
static let ink = Color(hex: "#0F0F0F") static let ink = Color(UIColor { t in
static let inkSecondary = Color(hex: "#6B6B6B") t.userInterfaceStyle == .dark
static let inkTertiary = Color(hex: "#ABABAB") ? UIColor(white: 0.93, alpha: 1)
static let inkQuaternary = Color(hex: "#D4D4D4") : 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 // MARK: Semantic
static let positive = Color(hex: "#16A34A") static let positive = Color(hex: "#16A34A")
@@ -18,9 +76,13 @@ enum AppTheme {
static let interactive = Color(hex: "#2563EB") static let interactive = Color(hex: "#2563EB")
// MARK: Chrome // MARK: Chrome
static let separator = Color.black.opacity(0.07) static let separator = Color(UIColor.separator)
static let cardShadow = Color.black.opacity(0.05) static let cardShadow = Color(UIColor { t in
static let cardBorderColor = Color.black.opacity(0.07) t.userInterfaceStyle == .dark
? UIColor.clear
: UIColor.black.withAlphaComponent(0.048)
})
static let cardBorderColor = Color(UIColor.separator)
// MARK: Metrics // MARK: Metrics
static let radius: CGFloat = 14 static let radius: CGFloat = 14
@@ -33,23 +95,13 @@ enum AppTheme {
static func display(_ size: CGFloat = 32) -> Font { static func display(_ size: CGFloat = 32) -> Font {
.system(size: size, weight: .semibold, design: .default) .system(size: size, weight: .semibold, design: .default)
} }
static func title(_ size: CGFloat = 20) -> Font { static func title(_ size: CGFloat = 20) -> Font { .system(size: size, weight: .semibold) }
.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 headline(_ size: CGFloat = 15) -> Font { static func caption(_ size: CGFloat = 13) -> Font { .system(size: size, weight: .regular) }
.system(size: size, weight: .medium) static func micro(_ size: CGFloat = 11) -> 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 blue = interactive
static let green = positive static let green = positive
static let red = destructive static let red = destructive
@@ -82,9 +134,7 @@ struct CardStyle: ViewModifier {
} }
extension View { extension View {
func card(padded: Bool = true) -> some View { func card(padded: Bool = true) -> some View { modifier(CardStyle(padded: padded)) }
modifier(CardStyle(padded: padded))
}
@ViewBuilder func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View { @ViewBuilder func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
if condition { transform(self) } else { self } if condition { transform(self) } else { self }

View File

@@ -0,0 +1,117 @@
import XCTest
import Photos
@testable import NASBackup
@MainActor
final class BackupEngineTests: XCTestCase {
private func makeAssets(count: Int) -> [PhotoAsset] {
(0..<count).map { i in
PhotoAsset(
localIdentifier: "asset-\(i)",
filename: "photo_\(i).jpg",
creationDate: Date(),
mediaType: .image,
pixelWidth: 1024, pixelHeight: 768,
duration: 0, isScreenshot: false, isRAW: false
)
}
}
private func makeConnection() -> NASConnection {
NASConnection(host: "192.168.1.10", nasProtocol: .smb,
username: "user", password: "pass", remotePath: "/backup")
}
func test_backup_uploadsAllAssets() async throws {
let mock = MockNASService()
let photos = MockPhotoLibraryService(assets: makeAssets(count: 3))
let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
let result = try await engine.run(connection: makeConnection(), filter: BackupFilter())
XCTAssertEqual(result.uploadedCount, 3)
XCTAssertEqual(result.failedCount, 0)
XCTAssertEqual(mock.uploadCallCount, 3)
}
func test_backup_skipsExistingFiles_whenNewFilesOnly() async throws {
let mock = MockNASService()
mock.fileExistsAnswer = true
let photos = MockPhotoLibraryService(assets: makeAssets(count: 2))
let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
var filter = BackupFilter()
filter.newFilesOnly = true
let result = try await engine.run(connection: makeConnection(), filter: filter)
XCTAssertEqual(result.skippedCount, 2)
XCTAssertEqual(result.uploadedCount, 0)
}
func test_backup_handlesUploadFailure_gracefully() async throws {
let mock = MockNASService()
mock.shouldFail = true
let photos = MockPhotoLibraryService(assets: makeAssets(count: 2))
let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
let result = try await engine.run(connection: makeConnection(), filter: BackupFilter())
XCTAssertEqual(result.failedCount, 2)
XCTAssertEqual(result.uploadedCount, 0)
}
func test_backup_emitsProgressUpdates() async throws {
let mock = MockNASService()
let photos = MockPhotoLibraryService(assets: makeAssets(count: 5))
let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
var progressEvents = 0
let cancellable = engine.$job.sink { job in
if case .running = job.status { progressEvents += 1 }
}
defer { cancellable.cancel() }
_ = try await engine.run(connection: makeConnection(), filter: BackupFilter())
XCTAssertGreaterThan(progressEvents, 0, "Expected progress events during backup")
}
func test_backup_canBePaused_andResumed() async throws {
let mock = MockNASService()
mock.uploadDelay = 0.05
let photos = MockPhotoLibraryService(assets: makeAssets(count: 4))
let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
let task = Task { try await engine.run(connection: makeConnection(), filter: BackupFilter()) }
try await Task.sleep(nanoseconds: 70_000_000)
engine.pause()
let statusAfterPause = engine.job.status
engine.resume()
let result = try await task.value
if case .paused = statusAfterPause { } else {
XCTFail("Expected .paused status, got \(statusAfterPause)")
}
XCTAssertEqual(result.uploadedCount, 4)
}
}
// MARK: - MockPhotoLibraryService
final class MockPhotoLibraryService: PhotoLibraryProtocol {
var authorizationStatus: PHAuthorizationStatus { .authorized }
private let assets: [PhotoAsset]
init(assets: [PhotoAsset]) { self.assets = assets }
func requestAuthorization() async -> PHAuthorizationStatus { .authorized }
func fetchAssets(filter: BackupFilter) -> [PhotoAsset] { assets }
func exportAsset(_ asset: PhotoAsset) async throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
try Data("mock-data".utf8).write(to: url)
return url
}
}

View File

@@ -0,0 +1,47 @@
import XCTest
@testable import NASBackup
@MainActor
final class ConnectViewModelTests: XCTestCase {
func test_canConnect_isFalse_whenFieldsEmpty() {
let vm = ConnectViewModel()
XCTAssertFalse(vm.canConnect)
}
func test_canConnect_isTrue_whenAllFieldsFilled() {
let vm = ConnectViewModel()
vm.host = "192.168.1.10"
vm.username = "admin"
vm.password = "secret"
XCTAssertTrue(vm.canConnect)
}
func test_connectionState_becomesVerified_onSuccess() async {
let vm = ConnectViewModel()
vm.host = "192.168.1.10"
vm.username = "admin"
vm.password = "secret"
// Override the service factory so no real network call is made.
let mock = MockNASService()
await vm.verifyWith(transfer: mock)
XCTAssertTrue(vm.isConnected)
XCTAssertNil(vm.verifyError)
}
func test_connectionState_becomesFailed_onError() async {
let vm = ConnectViewModel()
vm.host = "192.168.1.10"
vm.username = "admin"
vm.password = "wrong"
let mock = MockNASService()
mock.shouldFail = true
await vm.verifyWith(transfer: mock)
XCTAssertFalse(vm.isConnected)
XCTAssertNotNil(vm.verifyError)
}
}

View File

@@ -0,0 +1,48 @@
import XCTest
@testable import NASBackup
final class LANMonitorTests: XCTestCase {
func test_ssidMatch_triggersCallback() async {
let monitor = LANMonitor.shared
let trusted = ["HomeNet"]
var triggered = false
monitor.onTrustedNetworkJoined = { _ in triggered = true }
// Inject known SSID via checkAndTriggerIfTrusted using a subclass stub
// Since fetchCurrentSSID uses NEHotspotNetwork (requires device), we test
// the guard logic directly through checkAndTriggerIfTrusted with a mock SSID.
let stub = LANMonitorSSIDStub(ssid: "HomeNet")
await stub.checkAndTrigger(trustedSSIDs: trusted) { ssid in triggered = true }
XCTAssertTrue(triggered)
}
func test_ssidMismatch_doesNotTrigger() async {
var triggered = false
let stub = LANMonitorSSIDStub(ssid: "RandomCafe")
await stub.checkAndTrigger(trustedSSIDs: ["HomeNet"]) { _ in triggered = true }
XCTAssertFalse(triggered)
}
func test_trustedSSIDs_caseInsensitiveMatch() async {
var triggered = false
let stub = LANMonitorSSIDStub(ssid: "homenet")
let trusted = ["HomeNet"]
// Case-sensitive check verify current behaviour; adjust if policy changes.
await stub.checkAndTrigger(trustedSSIDs: trusted) { _ in triggered = true }
// Current implementation is case-sensitive; update assertion if behaviour changes.
XCTAssertFalse(triggered, "Current implementation is case-sensitive")
}
}
// Lightweight stub that bypasses NEHotspotNetwork entitlement requirement.
struct LANMonitorSSIDStub {
let ssid: String?
func checkAndTrigger(trustedSSIDs: [String], callback: (String) -> Void) async {
guard let ssid, trustedSSIDs.contains(ssid) else { return }
callback(ssid)
}
}

View File

@@ -0,0 +1,49 @@
import Foundation
@testable import NASBackup
final class MockNASService: NASTransferProtocol {
var shouldFail: Bool = false
var uploadDelay: TimeInterval = 0
var fakeItems: [NASItem] = []
var fileExistsAnswer: Bool = false
private(set) var connectCalled = false
private(set) var uploadCallCount = 0
private(set) var lastUploadedPath: String?
var isConnected: Bool = false
func connect(to host: String, port: Int, username: String, password: String) async throws {
connectCalled = true
if shouldFail { throw MockError.connectionFailed }
isConnected = true
}
func disconnect() { isConnected = false }
func listDirectory(at path: String) async throws -> [NASItem] {
if shouldFail { throw MockError.connectionFailed }
return fakeItems
}
func createDirectory(at path: String) async throws {
if shouldFail { throw MockError.connectionFailed }
}
func fileExists(at remotePath: String) async throws -> Bool { fileExistsAnswer }
func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws {
if uploadDelay > 0 {
try await Task.sleep(nanoseconds: UInt64(uploadDelay * 1_000_000_000))
}
if shouldFail { throw MockError.uploadFailed }
progress(100, 100)
uploadCallCount += 1
lastUploadedPath = remotePath
}
}
enum MockError: Error {
case connectionFailed
case uploadFailed
}

View File

@@ -0,0 +1,22 @@
import XCTest
@testable import NASBackup
final class NASProtocolTests: XCTestCase {
func test_allCases_haveLocalizedDescriptions() {
for proto in NASProtocol.allCases {
XCTAssertFalse(
proto.localizedDescription.isEmpty,
"\(proto.rawValue) has no localizedDescription"
)
}
}
func test_smb_defaultPort_is445() {
XCTAssertEqual(NASProtocol.smb.defaultPort, 445)
}
func test_sftp_defaultPort_is22() {
XCTAssertEqual(NASProtocol.sftp.defaultPort, 22)
}
}

View File

@@ -36,6 +36,8 @@ targets:
- Shared - Shared
resources: resources:
- Assets.xcassets - Assets.xcassets
- path: App/PrivacyInfo.xcprivacy
buildPhase: resources
info: info:
path: App/Info.plist path: App/Info.plist
properties: properties:
@@ -74,3 +76,17 @@ targets:
product: SMBClient product: SMBClient
- package: Citadel - package: Citadel
product: Citadel product: Citadel
NASBackupTests:
type: bundle.unit-test
platform: iOS
sources:
- Tests
dependencies:
- target: NASBackup
settings:
base:
TEST_HOST: $(BUILT_PRODUCTS_DIR)/NASBackup.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/NASBackup
BUNDLE_LOADER: $(TEST_HOST)
SWIFT_VERSION: "5.9"
IPHONEOS_DEPLOYMENT_TARGET: "16.0"