diff --git a/App/NASBackupApp.swift b/App/NASBackupApp.swift index 6dec0f9..95cb105 100644 --- a/App/NASBackupApp.swift +++ b/App/NASBackupApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import Photos @main struct NASBackupApp: App { @@ -14,6 +15,7 @@ struct NASBackupApp: App { .environmentObject(store) .environmentObject(engine) .environmentObject(lanMonitor) + .preferredColorScheme(store.appearanceMode.resolvedScheme) } } } @@ -22,10 +24,21 @@ struct RootView: View { @EnvironmentObject var store: ConnectionStore var body: some View { - if store.savedConnection != nil { - LoginView() - } else { - ConnectView() + Group { + if store.savedConnection == nil { + 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) } } diff --git a/App/PrivacyInfo.xcprivacy b/App/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..04dd96c --- /dev/null +++ b/App/PrivacyInfo.xcprivacy @@ -0,0 +1,44 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhotosorVideos + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/Assets.xcassets/ugreen_logo.imageset/Contents.json b/Assets.xcassets/ugreen_logo.imageset/Contents.json new file mode 100644 index 0000000..bbc51d1 --- /dev/null +++ b/Assets.xcassets/ugreen_logo.imageset/Contents.json @@ -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 + } +} diff --git a/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 1.png b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 1.png new file mode 100644 index 0000000..07ffced Binary files /dev/null and b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 1.png differ diff --git a/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 2.png b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 2.png new file mode 100644 index 0000000..07ffced Binary files /dev/null and b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 2.png differ diff --git a/Assets.xcassets/ugreen_logo.imageset/ugreen_logo.png b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo.png new file mode 100644 index 0000000..07ffced Binary files /dev/null and b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo.png differ diff --git a/Assets.xcassets/ugreen_logo.png b/Assets.xcassets/ugreen_logo.png new file mode 100644 index 0000000..07ffced Binary files /dev/null and b/Assets.xcassets/ugreen_logo.png differ diff --git a/Core/Models/NASConnection.swift b/Core/Models/NASConnection.swift index b959b8e..2c277b4 100644 --- a/Core/Models/NASConnection.swift +++ b/Core/Models/NASConnection.swift @@ -10,6 +10,8 @@ enum NASProtocol: String, Codable, CaseIterable { case .sftp: return 22 } } + + var localizedDescription: String { rawValue } } struct NASConnection: Codable, Identifiable, Equatable { @@ -18,10 +20,15 @@ struct NASConnection: Codable, Identifiable, Equatable { var port: Int var nasProtocol: NASProtocol var username: String - var password: String + var password: String // Runtime only — persisted in Keychain, not UserDefaults var remotePath: 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( host: String, port: Int? = nil, @@ -39,4 +46,16 @@ struct NASConnection: Codable, Identifiable, Equatable { self.remotePath = remotePath 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() + } } diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index a5ff198..24ed423 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -8,106 +8,117 @@ struct BackupView: View { var body: some View { ZStack { - Color.white.ignoresSafeArea() + AppTheme.background.ignoresSafeArea() - ScrollView { - VStack(spacing: AppTheme.sectionGap) { - ringSection - nasRow - optionsCard - actionButton + VStack(spacing: 0) { + Spacer(minLength: 0) + + // ─── Ring hero ─────────────────────────────────────── + ringHero + .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))) } - .padding(.horizontal, AppTheme.hPad) - .padding(.top, 24) - .padding(.bottom, 48) + + Spacer(minLength: 0) + + // ─── NAS status ─────────────────────────────────────── + nasStatusRow + .padding(.horizontal, AppTheme.hPad) + .padding(.bottom, 16) + + // ─── 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) } } - // MARK: — Ring + // MARK: — Ring hero - private var ringSection: some View { - VStack(spacing: 20) { - ZStack { - ProgressRing( - progress: engine.job.progress, - lineWidth: 8, - size: 180, - color: ringColor, - backgroundColor: Color(hex: "#EBEBEB") - ) + private var ringHero: some View { + ZStack { + ProgressRing( + progress: engine.job.progress, + lineWidth: 7, + size: 220, + color: ringColor, + backgroundColor: AppTheme.inkQuaternary.opacity(0.4) + ) + .accessibilityLabel("Backup progress") + .accessibilityValue("\(Int(engine.job.progress * 100)) percent") - VStack(spacing: 3) { - Text(percentText) - .font(.system(size: 40, weight: .semibold, design: .rounded)) - .foregroundStyle(AppTheme.ink) - .contentTransition(.numericText()) + VStack(spacing: 4) { + Text(percentText) + .font(.system(size: 44, weight: .semibold, design: .rounded)) + .foregroundStyle(AppTheme.ink) + .contentTransition(.numericText()) + .monospacedDigit() - if case .running = engine.job.status { - Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)") - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.inkSecondary) - if let eta = engine.job.eta { - Text("~\(etaString(eta)) remaining") - .font(.system(size: 11, weight: .regular)) - .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) - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(AppTheme.inkSecondary) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Text(formatSpeed(engine.job.speedBytesPerSecond)) - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(AppTheme.inkTertiary) - } - } - .transition(.opacity) + ringSubtitle + .transition(.opacity) } } - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.status == .completed) + .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)") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkSecondary) + if let eta = engine.job.eta { + Text("~\(etaString(eta)) remaining") + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(AppTheme.inkTertiary) + } + Text(engine.job.currentFileName) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(AppTheme.inkTertiary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 160) + } + 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) + } } private var ringColor: Color { @@ -120,11 +131,32 @@ struct BackupView: View { private var percentText: String { "\(Int(engine.job.progress * 100))%" } - private func statCell(_ value: String, label: String, color: Color = AppTheme.ink) -> some View { - VStack(spacing: 2) { + // MARK: — Completion strip + + 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) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(color) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(valueColor) + .monospacedDigit() Text(label) .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) @@ -133,21 +165,21 @@ struct BackupView: View { .padding(.vertical, 14) } - private var dividerLine: some View { + private var thinDivider: some View { Rectangle() .fill(AppTheme.separator) .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) { ZStack { Circle() .fill(AppTheme.surfaceSunken) - .frame(width: 32, height: 32) + .frame(width: 34, height: 34) Image(systemName: "externaldrive") .font(.system(size: 13, weight: .medium)) .foregroundStyle(AppTheme.inkSecondary) @@ -158,98 +190,54 @@ struct BackupView: View { Text(conn.host) .font(AppTheme.headline()) .foregroundStyle(AppTheme.ink) - Text(conn.username) - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.inkSecondary) + Text(conn.nasProtocol.rawValue) + .font(AppTheme.micro(10)) + .foregroundStyle(AppTheme.inkTertiary) } + } else { + Text("No NAS configured") + .font(AppTheme.body()) + .foregroundStyle(AppTheme.inkTertiary) } Spacer() HStack(spacing: 5) { Circle() - .fill(AppTheme.positive) + .fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary) .frame(width: 6, height: 6) - Text("Live") + Text(engine.job.status.isActive ? "Active" : "Ready") .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) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) } - // MARK: — Options + // MARK: — Photos access nudge - private var optionsCard: 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() } }) { - HStack { - Image(systemName: "photo.on.rectangle") - .font(.system(size: 14, weight: .regular)) - .foregroundStyle(vm.photosAuthStatus == .authorized - ? AppTheme.inkSecondary : AppTheme.destructive) - .frame(width: 20) - Text("Photos access") - .font(AppTheme.body()) - .foregroundStyle(AppTheme.ink) - Spacer() - Text(photosLabel) - .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) + private var photosAccessRow: some View { + Button(action: { Task { await vm.requestPhotosAccess() } }) { + HStack(spacing: 10) { + Image(systemName: "photo.on.rectangle") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AppTheme.destructive) .frame(width: 20) - Text(label) - .font(AppTheme.body()) - .foregroundStyle(AppTheme.ink) + Text("Grant photo library access to enable backup") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkSecondary) Spacer() Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } - .padding(.horizontal, 16) - .padding(.vertical, 14) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(AppTheme.destructive.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } .buttonStyle(ScaleButtonStyle()) } @@ -266,24 +254,27 @@ struct BackupView: View { .buttonStyle(PrimaryButtonStyle( color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink )) + .accessibilityHint("Starts backing up your photos to the NAS") case .running: Button { engine.pause() } label: { Text("Pause") } .buttonStyle(GhostButtonStyle()) + .accessibilityLabel("Pause backup") case .paused: HStack(spacing: 10) { Button { engine.resume() } label: { Text("Resume") } .buttonStyle(PrimaryButtonStyle()) + .accessibilityLabel("Resume backup") Button { engine.cancel() } label: { Text("Cancel") } .buttonStyle(GhostButtonStyle()) .frame(width: 90) + .accessibilityLabel("Cancel backup") } case .preparing: HStack(spacing: 8) { - ProgressView() - .scaleEffect(0.8) + ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary) Text("Preparing…") .font(AppTheme.body()) .foregroundStyle(AppTheme.inkSecondary) @@ -291,7 +282,7 @@ struct BackupView: View { .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() { @@ -299,12 +290,6 @@ struct BackupView: View { 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 { Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s" } diff --git a/Features/Browse/BrowseView.swift b/Features/Browse/BrowseView.swift index 0827931..d8ba12d 100644 --- a/Features/Browse/BrowseView.swift +++ b/Features/Browse/BrowseView.swift @@ -15,7 +15,7 @@ struct BrowseView: View { var body: some View { NavigationStack { ZStack { - Color.white.ignoresSafeArea() + AppTheme.background.ignoresSafeArea() VStack(spacing: 0) { // Path bar @@ -40,21 +40,31 @@ struct BrowseView: View { if isLoading { Spacer() - ProgressView() - .tint(AppTheme.inkTertiary) + VStack(spacing: 10) { + ProgressView() + .tint(AppTheme.inkTertiary) + Text("Loading…") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + } Spacer() } else if let error { Spacer() VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") - .font(.system(size: 28, weight: .light)) + .font(.system(size: 28, weight: .ultraLight)) .foregroundStyle(AppTheme.inkQuaternary) - Text(error) - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.inkSecondary) - .multilineTextAlignment(.center) + VStack(spacing: 4) { + Text("Could not load folder") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AppTheme.inkSecondary) + Text(error) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + .multilineTextAlignment(.center) + } } - .padding() + .padding(.horizontal, 32) Spacer() } else { List { diff --git a/Features/Connect/ConnectView.swift b/Features/Connect/ConnectView.swift index 09df8e2..98e3f0b 100644 --- a/Features/Connect/ConnectView.swift +++ b/Features/Connect/ConnectView.swift @@ -9,30 +9,33 @@ struct ConnectView: View { var body: some View { NavigationStack { ZStack { - AppTheme.surfaceSunken.ignoresSafeArea() + AppTheme.background.ignoresSafeArea() ScrollView { VStack(spacing: 0) { - // Logo + title - VStack(spacing: 10) { + // Header + VStack(spacing: 20) { + // App icon Image("nas_logo_clean") .resizable() .scaledToFit() - .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) + .frame(width: 52, height: 52) + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + .shadow(color: .black.opacity(0.08), radius: 14, x: 0, y: 4) .padding(.top, 44) - Text("NAS Connect") - .font(.system(size: 26, weight: .semibold)) - .foregroundStyle(AppTheme.ink) + // Title + VStack(spacing: 6) { + Text("Kisani") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(AppTheme.ink) - Text("Connect to your UGreen or any SMB / SFTP NAS") - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.inkTertiary) - .multilineTextAlignment(.center) + // UGreen NAS compatibility badge + UGreenCompatibilityBadge() + } } - .padding(.bottom, 32) + .padding(.bottom, 28) .padding(.horizontal, AppTheme.hPad) // Protocol picker @@ -50,7 +53,7 @@ struct ConnectView: View { .padding(.vertical, 7) .background( RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(vm.selectedProtocol == proto ? Color.white : Color.clear) + .fill(vm.selectedProtocol == proto ? AppTheme.surfaceRaised : Color.clear) .shadow( color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear, radius: 3, x: 0, y: 1 @@ -61,7 +64,7 @@ struct ConnectView: View { } } .padding(3) - .background(Color.black.opacity(0.06)) + .background(AppTheme.surfaceSunken) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 20) @@ -90,19 +93,14 @@ struct ConnectView: View { } .padding(.horizontal, 16) .padding(.vertical, 14) - .background(Color.white) + .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) - HStack(spacing: 20) { - Text("IP Address, ex: 192.168.1.10") - .font(AppTheme.micro(11)) - .foregroundStyle(AppTheme.inkTertiary) - Text("NAS Name, ex: MY_NAS") - .font(AppTheme.micro(11)) - .foregroundStyle(AppTheme.inkTertiary) - } - .padding(.leading, 4) + Text("e.g. 192.168.1.10 or MY_NAS") + .font(AppTheme.micro(11)) + .foregroundStyle(AppTheme.inkTertiary) + .padding(.leading, 4) } .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 12) @@ -163,7 +161,7 @@ struct ConnectView: View { .padding(.horizontal, 16) .padding(.vertical, 14) } - .background(Color.white) + .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous) @@ -199,7 +197,7 @@ struct ConnectView: View { } .padding(.horizontal, 16) .padding(.vertical, 14) - .background(Color.white) + .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } @@ -255,25 +253,11 @@ struct ConnectView: View { .disabled(!vm.canConnect) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected) - // Help - 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) + Spacer().frame(height: 48) } } } - .navigationTitle("NAS Connect") - .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(true) .navigationDestination(isPresented: $navigateToDashboard) { MainTabView() } .sheet(isPresented: $vm.showFolderBrowser) { if let conn = ConnectionStore.shared.savedConnection { diff --git a/Features/Connect/ConnectViewModel.swift b/Features/Connect/ConnectViewModel.swift index 11b9412..7f1f0fa 100644 --- a/Features/Connect/ConnectViewModel.swift +++ b/Features/Connect/ConnectViewModel.swift @@ -24,6 +24,11 @@ final class ConnectViewModel: ObservableObject { } func verify() async { + let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService() + await verifyWith(transfer: service) + } + + func verifyWith(transfer: any NASTransferProtocol) async { isVerifying = true isConnected = false verifyError = nil @@ -36,11 +41,10 @@ final class ConnectViewModel: ObservableObject { remotePath: remotePath ) - let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService() do { - try await service.connect(to: connection.host, port: connection.port, - username: connection.username, password: connection.password) - service.disconnect() + try await transfer.connect(to: connection.host, port: connection.port, + username: connection.username, password: connection.password) + transfer.disconnect() isConnected = true ConnectionStore.shared.savedConnection = connection } catch let e as BackupError { diff --git a/Features/History/HistoryView.swift b/Features/History/HistoryView.swift index 58352e5..a8621d1 100644 --- a/Features/History/HistoryView.swift +++ b/Features/History/HistoryView.swift @@ -5,29 +5,22 @@ struct HistoryView: View { var body: some View { ZStack { - Color.white.ignoresSafeArea() + AppTheme.background.ignoresSafeArea() if store.historyEntries.isEmpty { - VStack(spacing: 12) { - 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) - } + emptyState } else { List { ForEach(store.historyEntries) { entry in HistoryRowView(entry: entry) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) + .listRowBackground(AppTheme.background) } - .onDelete { offsets in - store.removeHistoryEntries(at: offsets) - } + .onDelete { store.removeHistoryEntries(at: $0) } } .listStyle(.plain) + .scrollContentBackground(.hidden) } } .navigationTitle("History") @@ -36,32 +29,60 @@ struct HistoryView: View { ToolbarItem(placement: .navigationBarTrailing) { Button("Clear") { store.clearHistory() } .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 { let entry: BackupHistoryEntry private var hasErrors: Bool { entry.failedCount > 0 } + private var statusColor: Color { hasErrors ? AppTheme.destructive : AppTheme.positive } var body: some View { VStack(spacing: 0) { HStack(alignment: .top, spacing: 14) { + // Status indicator Circle() - .fill(hasErrors ? AppTheme.destructive : AppTheme.positive) - .frame(width: 7, height: 7) - .padding(.top, 5) + .fill(statusColor) + .frame(width: 6, height: 6) + .padding(.top, 6) + // Content VStack(alignment: .leading, spacing: 5) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(entry.date, style: .date) - .font(.system(size: 15, weight: .medium)) + // Date + badges + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(entry.date, format: .dateTime.month(.abbreviated).day().year()) + .font(.system(size: 14, weight: .medium)) .foregroundStyle(AppTheme.ink) + Text(entry.date, format: .dateTime.hour().minute()) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(AppTheme.inkTertiary) + + Spacer() + if entry.triggeredByLAN { Text("AUTO") .font(.system(size: 9, weight: .semibold)) @@ -73,37 +94,25 @@ struct HistoryRowView: View { .stroke(AppTheme.inkQuaternary, lineWidth: 0.75) ) } - - Spacer() - - Text(entry.date, style: .time) - .font(AppTheme.micro(11)) - .foregroundStyle(AppTheme.inkTertiary) } - HStack(spacing: 8) { - Text("\(entry.uploadedCount) uploaded") - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.inkSecondary) + // Stat line + statLine - 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) + .foregroundStyle(AppTheme.inkQuaternary) + midDot + Text(formatDuration(entry.durationSeconds)) + .foregroundStyle(AppTheme.inkQuaternary) + if entry.totalBytes > 0 { + midDot + Text(formatBytes(entry.totalBytes)) + .foregroundStyle(AppTheme.inkQuaternary) } } - - Text(entry.nasHost) - .font(AppTheme.micro(11)) - .foregroundStyle(AppTheme.inkQuaternary) + .font(.system(size: 11, weight: .regular)) } } .padding(.horizontal, 16) @@ -112,13 +121,48 @@ struct HistoryRowView: View { Rectangle() .fill(AppTheme.separator) .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() .fill(AppTheme.inkQuaternary) .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) + } } diff --git a/Features/Login/LoginView.swift b/Features/Login/LoginView.swift index 6bca4a7..60e7d39 100644 --- a/Features/Login/LoginView.swift +++ b/Features/Login/LoginView.swift @@ -1,108 +1,94 @@ import SwiftUI struct LoginView: View { + @EnvironmentObject var store: ConnectionStore @StateObject private var vm = LoginViewModel() - @State private var navigateToDashboard = false @State private var logoAppeared = false var body: some View { - NavigationStack { - ZStack { - Color.white.ignoresSafeArea() + ZStack { + AppTheme.background.ignoresSafeArea() - VStack(spacing: 0) { - Spacer() + VStack(spacing: 0) { + Spacer() - // Logo - Image("nas_logo_clean") - .resizable() - .scaledToFit() - .frame(width: 72, height: 72) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - .shadow(color: .black.opacity(0.08), radius: 16, x: 0, y: 4) - .scaleEffect(logoAppeared ? 1 : 0.8) - .opacity(logoAppeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: logoAppeared) - - Spacer().frame(height: 32) - - // Device + account - VStack(spacing: 6) { - Text("Forge") - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(AppTheme.ink) - - 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()) - .foregroundStyle(AppTheme.inkSecondary) - } - } + // Logo + Image("nas_logo_clean") + .resizable() + .scaledToFit() + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .shadow(color: .black.opacity(0.08), radius: 16, x: 0, y: 4) + .scaleEffect(logoAppeared ? 1 : 0.8) .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.1), value: logoAppeared) - Spacer().frame(height: 40) + Spacer().frame(height: 32) - // Face ID button - Button(action: { vm.authenticateWithFaceID() }) { - HStack(spacing: 8) { - if vm.isAuthenticating { - ProgressView() - .tint(.white) - .scaleEffect(0.8) - } else { - Image(systemName: "faceid") - .font(.system(size: 16, weight: .medium)) - Text("Sign in with Face ID") - } - } - } - .buttonStyle(PrimaryButtonStyle()) - .padding(.horizontal, AppTheme.hPad) - .disabled(vm.isAuthenticating) - .opacity(logoAppeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.3), value: logoAppeared) - - if let error = vm.authError { - Text(error) - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.destructive) - .padding(.top, 12) - .transition(.opacity.combined(with: .move(edge: .top))) - } - - Spacer() - - // More - Button("More options") { vm.showMoreSheet = true } - .font(.system(size: 13, weight: .regular)) + // Title + account + VStack(spacing: 5) { + Text("Kisani") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(AppTheme.ink) + Text("Sign in to continue") + .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) - .padding(.bottom, 44) - .opacity(logoAppeared ? 1 : 0) - .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared) } - } - .navigationDestination(isPresented: $navigateToDashboard) { MainTabView() } - .sheet(isPresented: $vm.showMoreSheet) { - MoreOptionsSheet(isPresented: $vm.showMoreSheet) { - vm.showMoreSheet = false - navigateToDashboard = true + .opacity(logoAppeared ? 1 : 0) + .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared) + + Spacer().frame(height: 40) + + // Face ID button + Button(action: { vm.authenticateWithFaceID() }) { + HStack(spacing: 8) { + if vm.isAuthenticating { + ProgressView() + .tint(AppTheme.inkInverse) + .scaleEffect(0.8) + } else { + Image(systemName: "faceid") + .font(.system(size: 16, weight: .medium)) + Text("Sign in with Face ID") + } + } } - .presentationDetents([.height(200)]) - .modifier(SheetCornerRadius(20)) + .buttonStyle(PrimaryButtonStyle()) + .padding(.horizontal, AppTheme.hPad) + .disabled(vm.isAuthenticating) + .opacity(logoAppeared ? 1 : 0) + .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.3), value: logoAppeared) + + if let error = vm.authError { + Text(error) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.destructive) + .padding(.top, 12) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + Spacer() + + // More + Button("More options") { vm.showMoreSheet = true } + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(AppTheme.inkTertiary) + .padding(.bottom, 44) + .opacity(logoAppeared ? 1 : 0) + .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared) } - .onAppear { - vm.onAuthenticated = { navigateToDashboard = true } - logoAppeared = true + } + .sheet(isPresented: $vm.showMoreSheet) { + MoreOptionsSheet(isPresented: $vm.showMoreSheet) { + vm.showMoreSheet = false + store.isSessionActive = true } + .presentationDetents([.height(200)]) + .modifier(SheetCornerRadius(20)) + } + .onAppear { + vm.onAuthenticated = { store.isSessionActive = true } + logoAppeared = true } } } @@ -138,7 +124,7 @@ struct MoreOptionsSheet: View { .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 16) } - .background(Color.white) + .background(AppTheme.background) } private func sheetRow( diff --git a/Features/Login/LoginViewModel.swift b/Features/Login/LoginViewModel.swift index c224d19..ff645f6 100644 --- a/Features/Login/LoginViewModel.swift +++ b/Features/Login/LoginViewModel.swift @@ -1,5 +1,6 @@ import Foundation import LocalAuthentication +import os.log @MainActor final class LoginViewModel: ObservableObject { @@ -9,26 +10,34 @@ final class LoginViewModel: ObservableObject { var onAuthenticated: (() -> Void)? + private let logger = Logger(subsystem: "com.albert.nasbackup", category: "Login") + func authenticateWithFaceID() { + logger.info("Face ID authentication started") isAuthenticating = true authError = nil let context = LAContext() var error: NSError? 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" isAuthenticating = false return } context.evaluatePolicy( .deviceOwnerAuthenticationWithBiometrics, - localizedReason: "Sign in to NASBackup" + localizedReason: "Sign in to Kisani" ) { [weak self] success, evalError in Task { @MainActor in self?.isAuthenticating = false if success { + self?.logger.info("Face ID authentication succeeded — advancing session") self?.onAuthenticated?() } 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 } } } diff --git a/Features/Onboarding/FolderSetupView.swift b/Features/Onboarding/FolderSetupView.swift new file mode 100644 index 0000000..4ff1489 --- /dev/null +++ b/Features/Onboarding/FolderSetupView.swift @@ -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 + } +} diff --git a/Features/Onboarding/PhotoPermissionView.swift b/Features/Onboarding/PhotoPermissionView.swift new file mode 100644 index 0000000..2d2aabb --- /dev/null +++ b/Features/Onboarding/PhotoPermissionView.swift @@ -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 + } +} diff --git a/Features/Settings/LANTriggerSection.swift b/Features/Settings/LANTriggerSection.swift index 6794eda..917927c 100644 --- a/Features/Settings/LANTriggerSection.swift +++ b/Features/Settings/LANTriggerSection.swift @@ -131,7 +131,7 @@ struct LANTriggerSection: View { .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 12) } - .background(Color.white) + .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift index 14d69fa..ba67341 100644 --- a/Features/Settings/SettingsView.swift +++ b/Features/Settings/SettingsView.swift @@ -6,10 +6,11 @@ struct SettingsView: View { var body: some View { ZStack { - Color.white.ignoresSafeArea() + AppTheme.background.ignoresSafeArea() ScrollView { VStack(spacing: AppTheme.sectionGap) { + appearanceSection LANTriggerSection() mediaSection quietHoursSection @@ -26,6 +27,45 @@ struct SettingsView: View { .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 private var mediaSection: some View { @@ -49,7 +89,7 @@ struct SettingsView: View { subtitle: "Skip files already on NAS", isOn: $store.backupFilter.newFilesOnly) } - .background(Color.white) + .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } @@ -78,7 +118,7 @@ struct SettingsView: View { subtitle: "Preserve battery on large libraries", isOn: $store.chargingOnlyMode) } - .background(Color.white) + .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.quietHoursEnabled) @@ -135,7 +175,7 @@ struct SettingsView: View { .padding(.vertical, 10) } } - .background(Color.white) + .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.isOnCellular) @@ -206,13 +246,15 @@ struct SettingsView: View { .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } .buttonStyle(ScaleButtonStyle()) + .accessibilityLabel("Open Tailscale app") + .accessibilityHint("Opens the Tailscale app to connect the VPN") } } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 12) } } - .background(Color.white) + .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.useTailscaleWhenRemote) @@ -233,7 +275,7 @@ struct SettingsView: View { hairline notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On") } - .background(Color.white) + .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } diff --git a/NASBackup.xcodeproj/project.pbxproj b/NASBackup.xcodeproj/project.pbxproj index c817431..5314304 100644 --- a/NASBackup.xcodeproj/project.pbxproj +++ b/NASBackup.xcodeproj/project.pbxproj @@ -7,26 +7,37 @@ objects = { /* 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 */; }; 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 */; }; 512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */; }; 57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.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 */; }; + 77A3FF7E2E4F506E7F9E5CCD /* FolderSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */; }; 7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36E23852EF12DC14A2DF2DC /* NASBackupApp.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 */; }; 84E3EE64F74D00104BF8694B /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */; }; 8657A3E4C5E377FE34840E9A /* BackupEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824FADB40473BFC36FE91F0A /* BackupEngine.swift */; }; 88879238876B1E5A1BE15DF3 /* SMBClient in Frameworks */ = {isa = PBXBuildFile; productRef = 9CEDCE7A5B4B267C66F39ABE /* SMBClient */; }; 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 */; }; + 96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */; }; 9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBD92D10D70B957192E271F /* SMBService.swift */; }; 9A2F717F6A8F4AA85FA636FC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 7BEB5BEBEB935BC8E2182A7B /* Citadel */; }; 9CF57AC05F30A51B6B5269DC /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B33F755DA5850D1FE3F695B /* AppTheme.swift */; }; A6276840D40211EB446293A9 /* BackupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC73AD75762D4357D171CDA4 /* BackupViewModel.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 */; }; B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C955FEF6710E3667C7A73A02 /* ProgressRing.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 */; }; C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D17A73A05A6FDF2B89C6B2 /* SettingsView.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 */; }; 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 */; }; ED0D2A1D05B5898BCCE9EA0A /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0969FC7D7E0EE313E29D55 /* ButtonStyles.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 */; }; /* 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 */ 055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASTransferProtocol.swift; sourceTree = ""; }; + 0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 277DF2E5F4E566C869EC29F3 /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = ""; }; 2B33F755DA5850D1FE3F695B /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASConnection.swift; sourceTree = ""; }; + 336D155A4D1C895215A4FFF2 /* BackupEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngineTests.swift; sourceTree = ""; }; + 3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderSetupView.swift; sourceTree = ""; }; 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = ""; }; 47E762898E351192399FC739 /* BackupJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupJob.swift; sourceTree = ""; }; 4AEFE4959C08B94AE9584A11 /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = ""; }; 4B67584BB6D705BCB3D40192 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = ""; }; 4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = ""; }; + 4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; + 50191596BF6E84CFAE7904D3 /* ConnectViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModelTests.swift; sourceTree = ""; }; 55EA454B783C2AEDABFB00A7 /* BackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupView.swift; sourceTree = ""; }; 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = ""; }; 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = ""; }; 6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = ""; }; + 7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASProtocolTests.swift; sourceTree = ""; }; + 7AC72ED4015671C51066AC3E /* PhotoPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPermissionView.swift; sourceTree = ""; }; 824FADB40473BFC36FE91F0A /* BackupEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngine.swift; sourceTree = ""; }; 83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderRow.swift; sourceTree = ""; }; + 86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHAsset+.swift"; sourceTree = ""; }; 8845003C87961174302AC990 /* ToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRow.swift; sourceTree = ""; }; 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = ""; }; @@ -66,18 +99,24 @@ A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = ""; }; B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = ""; }; + B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UGreenCompatibilityBadge.swift; sourceTree = ""; }; + BACE1D31086231C97DAD884D /* MockNASService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNASService.swift; sourceTree = ""; }; 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 = ""; }; CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = ""; }; + D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = ""; }; D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = ""; }; D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; }; DEBD92D10D70B957192E271F /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = ""; }; + 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 = ""; }; F03F0ED1A914F3392BFE5E4A /* BackupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupError.swift; sourceTree = ""; }; F09160B7B7A06BD900D0D87A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitorTests.swift; sourceTree = ""; }; F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; F5CE897758E9CD5BB8B80F63 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -102,6 +141,7 @@ 1AD5A741A7466C3510901C60 /* Connect */, 88D0BD5235374F2A264A0C96 /* History */, FDF5327105186A6456CF0B08 /* Login */, + 611BB6A8B01D82432C0A32EC /* Onboarding */, 147B04CA2B7A7723CCD13E93 /* Settings */, ); path = Features; @@ -115,6 +155,7 @@ 0A801C860EB4C4B22FD4D073 /* Features */, ADFBF8F90DF8352BD2833A66 /* Services */, 31C8DD8F9077E2B6C67A99BA /* Shared */, + D7CC50C0264BFFC23DA2C868 /* Tests */, 8308FE572D56B8045F17F6A0 /* Products */, ); sourceTree = ""; @@ -123,6 +164,8 @@ isa = PBXGroup; children = ( D87319355D902007618A91AE /* ConnectionStore.swift */, + 4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */, + D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */, ); path = Persistence; sourceTree = ""; @@ -136,6 +179,16 @@ path = Settings; sourceTree = ""; }; + 185383972A68402ADF89D64C /* Extensions */ = { + isa = PBXGroup; + children = ( + F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */, + 86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */, + 277DF2E5F4E566C869EC29F3 /* URL+.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 1AD5A741A7466C3510901C60 /* Connect */ = { isa = PBXGroup; children = ( @@ -149,6 +202,7 @@ isa = PBXGroup; children = ( 3E7F8DE77B7CDC7009B54D63 /* Components */, + 185383972A68402ADF89D64C /* Extensions */, 0B27021136C1E85D5B8CFF97 /* Persistence */, C874CB05A914EE933FCFD5C2 /* Theme */, ); @@ -162,10 +216,20 @@ 83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */, C955FEF6710E3667C7A73A02 /* ProgressRing.swift */, 8845003C87961174302AC990 /* ToggleRow.swift */, + B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */, ); path = Components; sourceTree = ""; }; + 611BB6A8B01D82432C0A32EC /* Onboarding */ = { + isa = PBXGroup; + children = ( + 3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */, + 7AC72ED4015671C51066AC3E /* PhotoPermissionView.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; 803154EA96E2431367806BAF /* Backup */ = { isa = PBXGroup; children = ( @@ -188,6 +252,7 @@ isa = PBXGroup; children = ( C755BFDD42D46A3FB490A4BB /* NASBackup.app */, + E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */, ); name = Products; sourceTree = ""; @@ -214,6 +279,7 @@ 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */, F5CE897758E9CD5BB8B80F63 /* Info.plist */, B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */, + 0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */, F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */, ); path = App; @@ -258,6 +324,18 @@ path = Theme; sourceTree = ""; }; + D7CC50C0264BFFC23DA2C868 /* Tests */ = { + isa = PBXGroup; + children = ( + 336D155A4D1C895215A4FFF2 /* BackupEngineTests.swift */, + 50191596BF6E84CFAE7904D3 /* ConnectViewModelTests.swift */, + F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */, + BACE1D31086231C97DAD884D /* MockNASService.swift */, + 7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */, + ); + path = Tests; + sourceTree = ""; + }; F75092ACBEF36F32816506F7 /* Models */ = { isa = PBXGroup; children = ( @@ -285,6 +363,7 @@ buildConfigurationList = F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */; buildPhases = ( E6D3CCCB60977A7DABFE7F19 /* Sources */, + 6DB4B780F7FFCAAC2B48E78F /* Resources */, BC332B29F6F9E065FF85AED8 /* Frameworks */, ); buildRules = ( @@ -300,6 +379,24 @@ productReference = C755BFDD42D46A3FB490A4BB /* NASBackup.app */; 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 */ /* Begin PBXProject section */ @@ -313,6 +410,10 @@ DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; + 88E89D71F1D8FA18A6A64BBF = { + DevelopmentTeam = ""; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "NASBackup" */; @@ -334,11 +435,35 @@ projectRoot = ""; targets = ( 32316B985B8906FE4CB97730 /* NASBackup */, + 88E89D71F1D8FA18A6A64BBF /* NASBackupTests */, ); }; /* 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 */ + 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 */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -357,8 +482,11 @@ DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */, C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */, F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */, + 30693CB8EE711577978EF88C /* Date+.swift in Sources */, 6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */, + 77A3FF7E2E4F506E7F9E5CCD /* FolderSetupView.swift in Sources */, 6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */, + 8EB77E535C82274276C1E328 /* KeychainStore.swift in Sources */, CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */, 7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */, 45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */, @@ -367,20 +495,50 @@ 7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */, 512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */, 8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */, + 4148416D31C8AC65FEE6394F /* PHAsset+.swift in Sources */, DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */, 57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */, + E24CEE0D51131D991038205D /* PhotoPermissionView.swift in Sources */, B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */, BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */, 9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */, + 13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */, A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */, C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */, B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */, + 96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */, + 10062AA91BC883AE597443BD /* URL+.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + EA438E47054B868AA6F6727D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 32316B985B8906FE4CB97730 /* NASBackup */; + targetProxy = 2F6BE82D286218B2DC8D8B98 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency 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 */ = { isa = XCBuildConfiguration; buildSettings = { @@ -397,6 +555,23 @@ }; 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 */ = { isa = XCBuildConfiguration; buildSettings = { @@ -554,6 +729,15 @@ defaultConfigurationIsVisible = 0; 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" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index c630daf..0d77571 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -1,5 +1,8 @@ import Foundation import UserNotifications +import os.log + +private let logger = Logger(subsystem: "com.albert.nasbackup", category: "BackupEngine") @MainActor final class BackupEngine: ObservableObject { @@ -7,11 +10,30 @@ final class BackupEngine: ObservableObject { @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 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( connection: NASConnection, @@ -36,11 +58,7 @@ final class BackupEngine: ObservableObject { let assets = photoService.fetchAssets(filter: filter) // 2. Connect to NAS - let transfer: any NASTransferProtocol - switch connection.nasProtocol { - case .smb: transfer = SMBService() - case .sftp: transfer = SFTPService() - } + let transfer = transferFactory(connection.nasProtocol) try await transfer.connect( to: host, @@ -102,6 +120,7 @@ final class BackupEngine: ObservableObject { job.fileCompleted(skipped: false) uploaded += 1 } catch { + logger.error("Upload failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") job.fileFailed() failed += 1 } diff --git a/Services/PhotoLibraryService.swift b/Services/PhotoLibraryService.swift index 946ff1b..5b32da5 100644 --- a/Services/PhotoLibraryService.swift +++ b/Services/PhotoLibraryService.swift @@ -44,25 +44,26 @@ final class PhotoLibraryService: PhotoLibraryProtocol { let result = PHAsset.fetchAssets(with: options) var assets: [PhotoAsset] = [] result.enumerateObjects { asset, _, _ in - let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot) - 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 } - if isRAW && !filter.includeRAW { return } + autoreleasepool { + let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot) + let resources = PHAssetResource.assetResources(for: asset) + let isRAW = resources.contains { $0.type == .alternatePhoto } + if isRAW && !filter.includeRAW { return } - let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier).jpg" + let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier).jpg" - assets.append(PhotoAsset( - localIdentifier: asset.localIdentifier, - filename: filename, - creationDate: asset.creationDate, - mediaType: asset.mediaType, - pixelWidth: asset.pixelWidth, - pixelHeight: asset.pixelHeight, - duration: asset.duration, - isScreenshot: isScreenshot, - isRAW: isRAW - )) + assets.append(PhotoAsset( + localIdentifier: asset.localIdentifier, + filename: filename, + creationDate: asset.creationDate, + mediaType: asset.mediaType, + pixelWidth: asset.pixelWidth, + pixelHeight: asset.pixelHeight, + duration: asset.duration, + isScreenshot: isScreenshot, + isRAW: isRAW + )) + } } return assets } diff --git a/Shared/Components/ButtonStyles.swift b/Shared/Components/ButtonStyles.swift index 7b4363f..7d41290 100644 --- a/Shared/Components/ButtonStyles.swift +++ b/Shared/Components/ButtonStyles.swift @@ -1,6 +1,6 @@ import SwiftUI -// MARK: — Primary (black fill) +// MARK: — Primary (ink fill) struct PrimaryButtonStyle: ButtonStyle { var color: Color = AppTheme.ink var height: CGFloat = 50 @@ -8,7 +8,7 @@ struct PrimaryButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 15, weight: .medium)) - .foregroundColor(.white) + .foregroundStyle(AppTheme.inkInverse) .frame(maxWidth: .infinity) .frame(height: height) .background(color) @@ -25,12 +25,12 @@ struct GhostButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 15, weight: .medium)) - .foregroundColor(AppTheme.ink) + .foregroundStyle(AppTheme.ink) .frame(maxWidth: .infinity) .frame(height: height) .overlay( RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous) - .stroke(AppTheme.cardBorderColor, lineWidth: 1) + .stroke(AppTheme.ink.opacity(0.25), lineWidth: 1) ) .scaleEffect(configuration.isPressed ? 0.97 : 1) .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed) @@ -52,7 +52,7 @@ struct PillButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 13, weight: .medium)) - .foregroundColor(.white) + .foregroundStyle(AppTheme.inkInverse) .padding(.horizontal, 14) .padding(.vertical, 7) .background(color) diff --git a/Shared/Components/ToggleRow.swift b/Shared/Components/ToggleRow.swift index 9abe073..895114d 100644 --- a/Shared/Components/ToggleRow.swift +++ b/Shared/Components/ToggleRow.swift @@ -29,6 +29,8 @@ struct ToggleRow: View { Toggle("", isOn: $isOn) .labelsHidden() .tint(AppTheme.ink) + .accessibilityLabel(title) + .accessibilityValue(isOn ? "On" : "Off") } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 12) diff --git a/Shared/Components/UGreenCompatibilityBadge.swift b/Shared/Components/UGreenCompatibilityBadge.swift new file mode 100644 index 0000000..110e99e --- /dev/null +++ b/Shared/Components/UGreenCompatibilityBadge.swift @@ -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)) + } +} diff --git a/Shared/Extensions/Date+.swift b/Shared/Extensions/Date+.swift new file mode 100644 index 0000000..95002a1 --- /dev/null +++ b/Shared/Extensions/Date+.swift @@ -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) + } +} diff --git a/Shared/Extensions/PHAsset+.swift b/Shared/Extensions/PHAsset+.swift new file mode 100644 index 0000000..50a4b99 --- /dev/null +++ b/Shared/Extensions/PHAsset+.swift @@ -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 } + } +} diff --git a/Shared/Extensions/URL+.swift b/Shared/Extensions/URL+.swift new file mode 100644 index 0000000..5b958f6 --- /dev/null +++ b/Shared/Extensions/URL+.swift @@ -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: "_") + } +} diff --git a/Shared/Persistence/ConnectionStore.swift b/Shared/Persistence/ConnectionStore.swift index cfb69d2..89aaaf0 100644 --- a/Shared/Persistence/ConnectionStore.swift +++ b/Shared/Persistence/ConnectionStore.swift @@ -15,7 +15,10 @@ final class ConnectionStore: ObservableObject { static let shared = ConnectionStore() @Published var savedConnection: NASConnection? { - didSet { saveUD(savedConnection, key: "savedConnection") } + didSet { + if let conn = savedConnection { SavedConnections.save(conn) } + else { SavedConnections.delete() } + } } @Published var backupFilter: BackupFilter { didSet { saveUD(backupFilter, key: "backupFilter") } @@ -42,6 +45,19 @@ final class ConnectionStore: ObservableObject { didSet { UserDefaults.standard.set(quietHoursEnd, forKey: "quietHoursEnd") } } + // Session — not persisted, resets on every app launch + @Published var isSessionActive = false + + // Onboarding — persisted so we only show each step once + @Published var onboardingPhotoShown: Bool { + didSet { UserDefaults.standard.set(onboardingPhotoShown, forKey: "onboardingPhotoShown") } + } + + // Appearance + @Published var appearanceMode: AppearanceMode { + didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: "appearanceMode") } + } + // Cellular & remote access @Published var allowCellularBackup: Bool { didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") } @@ -56,7 +72,7 @@ final class ConnectionStore: ObservableObject { @Published private(set) var historyEntries: [BackupHistoryEntry] = [] private init() { - savedConnection = ud(NASConnection.self, key: "savedConnection") + savedConnection = SavedConnections.load() backupFilter = ud(BackupFilter.self, key: "backupFilter") ?? BackupFilter() trustedSSIDs = UserDefaults.standard.stringArray(forKey: "trustedSSIDs") ?? [] lanTriggerEnabled = UserDefaults.standard.object(forKey: "lanTriggerEnabled") as? Bool ?? true @@ -65,6 +81,8 @@ final class ConnectionStore: ObservableObject { quietHoursEnabled = UserDefaults.standard.object(forKey: "quietHoursEnabled") as? Bool ?? false quietHoursStart = UserDefaults.standard.object(forKey: "quietHoursStart") as? Int ?? 23 quietHoursEnd = UserDefaults.standard.object(forKey: "quietHoursEnd") as? Int ?? 7 + onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown") + appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? "" diff --git a/Shared/Persistence/KeychainStore.swift b/Shared/Persistence/KeychainStore.swift new file mode 100644 index 0000000..f8e99eb --- /dev/null +++ b/Shared/Persistence/KeychainStore.swift @@ -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) + } +} diff --git a/Shared/Persistence/SavedConnections.swift b/Shared/Persistence/SavedConnections.swift new file mode 100644 index 0000000..1262a89 --- /dev/null +++ b/Shared/Persistence/SavedConnections.swift @@ -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) + } +} diff --git a/Shared/Theme/AppTheme.swift b/Shared/Theme/AppTheme.swift index ab8259e..9d581cd 100644 --- a/Shared/Theme/AppTheme.swift +++ b/Shared/Theme/AppTheme.swift @@ -1,55 +1,107 @@ import SwiftUI +import UIKit + +// MARK: — Appearance preference + +enum AppearanceMode: String, CaseIterable { + case system, light, dark + + var resolvedScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } + + var label: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + } + } + + var icon: String { + switch self { + case .system: return "circle.lefthalf.filled" + case .light: return "sun.max" + case .dark: return "moon" + } + } +} enum AppTheme { // MARK: — Surfaces - static let background = Color.white - static let surfaceRaised = Color.white - static let surfaceSunken = Color(hex: "#F5F5F5") + static let background = Color(UIColor.systemBackground) + static let surfaceRaised = Color(UIColor.systemBackground) + static let surfaceSunken = Color(UIColor { t in + t.userInterfaceStyle == .dark + ? UIColor(white: 0.11, alpha: 1) + : UIColor(white: 0.961, alpha: 1) + }) // MARK: — Ink - static let ink = Color(hex: "#0F0F0F") - static let inkSecondary = Color(hex: "#6B6B6B") - static let inkTertiary = Color(hex: "#ABABAB") - static let inkQuaternary = Color(hex: "#D4D4D4") + static let ink = Color(UIColor { t in + t.userInterfaceStyle == .dark + ? UIColor(white: 0.93, alpha: 1) + : UIColor(red: 0.059, green: 0.059, blue: 0.059, alpha: 1) + }) + static let inkSecondary = Color(UIColor { t in + t.userInterfaceStyle == .dark + ? UIColor(white: 0.58, alpha: 1) + : UIColor(white: 0.42, alpha: 1) + }) + static let inkTertiary = Color(UIColor { t in + t.userInterfaceStyle == .dark + ? UIColor(white: 0.40, alpha: 1) + : UIColor(white: 0.67, alpha: 1) + }) + static let inkQuaternary = Color(UIColor { t in + t.userInterfaceStyle == .dark + ? UIColor(white: 0.22, alpha: 1) + : UIColor(white: 0.83, alpha: 1) + }) + + // Readable on AppTheme.ink background in both color schemes + static let inkInverse = Color(UIColor { t in + t.userInterfaceStyle == .dark + ? UIColor(red: 0.059, green: 0.059, blue: 0.059, alpha: 1) + : UIColor(white: 1.0, alpha: 1) + }) // MARK: — Semantic - static let positive = Color(hex: "#16A34A") - static let destructive = Color(hex: "#DC2626") - static let interactive = Color(hex: "#2563EB") + static let positive = Color(hex: "#16A34A") + static let destructive = Color(hex: "#DC2626") + static let interactive = Color(hex: "#2563EB") // MARK: — Chrome - static let separator = Color.black.opacity(0.07) - static let cardShadow = Color.black.opacity(0.05) - static let cardBorderColor = Color.black.opacity(0.07) + static let separator = Color(UIColor.separator) + static let cardShadow = Color(UIColor { t in + t.userInterfaceStyle == .dark + ? UIColor.clear + : UIColor.black.withAlphaComponent(0.048) + }) + static let cardBorderColor = Color(UIColor.separator) // MARK: — Metrics - static let radius: CGFloat = 14 - static let radiusLarge: CGFloat = 18 - static let hPad: CGFloat = 20 - static let cardPad: CGFloat = 16 - static let sectionGap: CGFloat = 28 + static let radius: CGFloat = 14 + static let radiusLarge: CGFloat = 18 + static let hPad: CGFloat = 20 + static let cardPad: CGFloat = 16 + static let sectionGap: CGFloat = 28 // MARK: — Typography static func display(_ size: CGFloat = 32) -> Font { .system(size: size, weight: .semibold, design: .default) } - static func title(_ size: CGFloat = 20) -> Font { - .system(size: size, weight: .semibold) - } - static func headline(_ size: CGFloat = 15) -> Font { - .system(size: size, weight: .medium) - } - static func body(_ size: CGFloat = 15) -> Font { - .system(size: size, weight: .regular) - } - static func caption(_ size: CGFloat = 13) -> Font { - .system(size: size, weight: .regular) - } - static func micro(_ size: CGFloat = 11) -> Font { - .system(size: size, weight: .medium) - } + static func title(_ size: CGFloat = 20) -> Font { .system(size: size, weight: .semibold) } + static func headline(_ size: CGFloat = 15) -> Font { .system(size: size, weight: .medium) } + static func body(_ size: CGFloat = 15) -> Font { .system(size: size, weight: .regular) } + static func caption(_ size: CGFloat = 13) -> Font { .system(size: size, weight: .regular) } + static func micro(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .medium) } - // MARK: — Back-compat aliases used by existing call sites + // MARK: — Back-compat aliases static let blue = interactive static let green = positive static let red = destructive @@ -82,9 +134,7 @@ struct CardStyle: ViewModifier { } extension View { - func card(padded: Bool = true) -> some View { - modifier(CardStyle(padded: padded)) - } + func card(padded: Bool = true) -> some View { modifier(CardStyle(padded: padded)) } @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> T) -> some View { if condition { transform(self) } else { self } diff --git a/Tests/BackupEngineTests.swift b/Tests/BackupEngineTests.swift new file mode 100644 index 0000000..d631347 --- /dev/null +++ b/Tests/BackupEngineTests.swift @@ -0,0 +1,117 @@ +import XCTest +import Photos +@testable import NASBackup + +@MainActor +final class BackupEngineTests: XCTestCase { + + private func makeAssets(count: Int) -> [PhotoAsset] { + (0.. 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 + } +} diff --git a/Tests/ConnectViewModelTests.swift b/Tests/ConnectViewModelTests.swift new file mode 100644 index 0000000..66dca3a --- /dev/null +++ b/Tests/ConnectViewModelTests.swift @@ -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) + } +} diff --git a/Tests/LANMonitorTests.swift b/Tests/LANMonitorTests.swift new file mode 100644 index 0000000..7535d8a --- /dev/null +++ b/Tests/LANMonitorTests.swift @@ -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) + } +} diff --git a/Tests/MockNASService.swift b/Tests/MockNASService.swift new file mode 100644 index 0000000..2a42c8f --- /dev/null +++ b/Tests/MockNASService.swift @@ -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 +} diff --git a/Tests/NASProtocolTests.swift b/Tests/NASProtocolTests.swift new file mode 100644 index 0000000..0db96c2 --- /dev/null +++ b/Tests/NASProtocolTests.swift @@ -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) + } +} diff --git a/project.yml b/project.yml index dc0d203..6972971 100644 --- a/project.yml +++ b/project.yml @@ -36,6 +36,8 @@ targets: - Shared resources: - Assets.xcassets + - path: App/PrivacyInfo.xcprivacy + buildPhase: resources info: path: App/Info.plist properties: @@ -74,3 +76,17 @@ targets: product: SMBClient - package: 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"