diff --git a/Core/Protocols/PhotoLibraryProtocol.swift b/Core/Protocols/PhotoLibraryProtocol.swift index f029865..71a1e90 100644 --- a/Core/Protocols/PhotoLibraryProtocol.swift +++ b/Core/Protocols/PhotoLibraryProtocol.swift @@ -26,6 +26,5 @@ struct BackupFilter: Codable, Sendable, Equatable { var includeVideos: Bool = true var includeScreenshots: Bool = false var includeRAW: Bool = false - var newFilesOnly: Bool = true var includeScreenRecordings: Bool = false } diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift index 0b9fe15..c23d865 100644 --- a/Features/Settings/SettingsView.swift +++ b/Features/Settings/SettingsView.swift @@ -138,10 +138,6 @@ struct SettingsView: View { hairline ToggleRow(icon: "rays", title: "RAW", isOn: $store.backupFilter.includeRAW) - hairline - ToggleRow(icon: "doc.badge.clock", title: "New files only", - subtitle: "Skip files already on NAS", - isOn: $store.backupFilter.newFilesOnly) } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) @@ -221,15 +217,19 @@ struct SettingsView: View { .foregroundStyle(AppTheme.inkTertiary) } Spacer() - if lanMonitor.currentSSID != nil { - let trusted = store.trustedSSIDs.contains(lanMonitor.currentSSID ?? "") - HStack(spacing: 4) { - Circle() - .fill(trusted ? AppTheme.positive : AppTheme.inkQuaternary) - .frame(width: 5, height: 5) + HStack(spacing: 4) { + Circle() + .fill(lanMonitor.isOnNetwork ? AppTheme.positive : AppTheme.inkQuaternary) + .frame(width: 5, height: 5) + if let ssid = lanMonitor.currentSSID { + let trusted = store.trustedSSIDs.contains(ssid) Text(trusted ? "Trusted" : "Unknown") .font(AppTheme.micro()) .foregroundStyle(trusted ? AppTheme.positive : AppTheme.inkTertiary) + } else { + Text(lanMonitor.isOnNetwork ? "On" : "Off") + .font(AppTheme.micro()) + .foregroundStyle(lanMonitor.isOnNetwork ? AppTheme.positive : AppTheme.inkTertiary) } } } @@ -490,11 +490,17 @@ struct SettingsView: View { sectionLabel("NOTIFICATIONS") VStack(spacing: 0) { - notifRow(icon: "bell", title: "On start", value: "Off") + ToggleRow(icon: "bell", title: "On start", + subtitle: "Notify when a backup begins", + isOn: $store.notifyOnStart) hairline - notifRow(icon: "checkmark.circle", title: "On complete", value: "On") + ToggleRow(icon: "checkmark.circle", title: "On complete", + subtitle: "Notify when all files are safe", + isOn: $store.notifyOnComplete) hairline - notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On") + ToggleRow(icon: "exclamationmark.triangle", title: "On errors", + subtitle: "Notify if any uploads fail", + isOn: $store.notifyOnErrors) } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) @@ -502,24 +508,6 @@ struct SettingsView: View { } } - private func notifRow(icon: String, title: String, value: String) -> some View { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.system(size: 14, weight: .regular)) - .foregroundStyle(AppTheme.inkSecondary) - .frame(width: 20) - Text(title) - .font(AppTheme.body()) - .foregroundStyle(AppTheme.ink) - Spacer() - Text(value) - .font(AppTheme.caption()) - .foregroundStyle(AppTheme.inkTertiary) - } - .padding(.horizontal, AppTheme.cardPad) - .padding(.vertical, 14) - } - private var hairline: some View { Rectangle() .fill(AppTheme.separator) diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index 48babba..72fcda7 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -185,7 +185,9 @@ final class BackupEngine: ObservableObject { // ── 5. Upload loop ───────────────────────────────────────────────── job.start(totalFiles: pendingAssets.count, totalBytes: 0) - NotificationService.notifyBackupStarted(count: pendingAssets.count) + if store.notifyOnStart { + NotificationService.notifyBackupStarted(count: pendingAssets.count) + } // Build initial queue (all queued) for SyncView queueItems = pendingAssets.map { asset in @@ -433,8 +435,10 @@ final class BackupEngine: ObservableObject { // Notifications if result.failedCount > 0 { - NotificationService.notifyBackupFailed(count: result.failedCount) - } else { + if store.notifyOnErrors { + NotificationService.notifyBackupFailed(count: result.failedCount) + } + } else if store.notifyOnComplete { NotificationService.notifyBackupCompleted( uploaded: result.uploadedCount, total: BackupStatusService.shared.snapshot.phoneTotal diff --git a/Shared/Persistence/ConnectionStore.swift b/Shared/Persistence/ConnectionStore.swift index 59eec98..57925fd 100644 --- a/Shared/Persistence/ConnectionStore.swift +++ b/Shared/Persistence/ConnectionStore.swift @@ -63,6 +63,17 @@ final class ConnectionStore: ObservableObject { didSet { UserDefaults.standard.set(localRetentionDays, forKey: "localRetentionDays") } } + // Notification preferences + @Published var notifyOnStart: Bool { + didSet { UserDefaults.standard.set(notifyOnStart, forKey: "notifyOnStart") } + } + @Published var notifyOnComplete: Bool { + didSet { UserDefaults.standard.set(notifyOnComplete, forKey: "notifyOnComplete") } + } + @Published var notifyOnErrors: Bool { + didSet { UserDefaults.standard.set(notifyOnErrors, forKey: "notifyOnErrors") } + } + // Auto-backup via LAN trigger (trusted Wi-Fi join) @Published var autoBackupEnabled: Bool { didSet { UserDefaults.standard.set(autoBackupEnabled, forKey: "autoBackupEnabled") } @@ -99,6 +110,9 @@ final class ConnectionStore: ObservableObject { onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown") appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system localRetentionDays = UserDefaults.standard.object(forKey: "localRetentionDays") as? Int ?? 0 + notifyOnStart = UserDefaults.standard.object(forKey: "notifyOnStart") as? Bool ?? false + notifyOnComplete = UserDefaults.standard.object(forKey: "notifyOnComplete") as? Bool ?? true + notifyOnErrors = UserDefaults.standard.object(forKey: "notifyOnErrors") as? Bool ?? true autoBackupEnabled = UserDefaults.standard.object(forKey: "autoBackupEnabled") as? Bool ?? true autoBackupOnOpen = UserDefaults.standard.object(forKey: "autoBackupOnOpen") as? Bool ?? false allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false diff --git a/Tests/BackupEngineTests.swift b/Tests/BackupEngineTests.swift index d631347..763acd5 100644 --- a/Tests/BackupEngineTests.swift +++ b/Tests/BackupEngineTests.swift @@ -13,7 +13,7 @@ final class BackupEngineTests: XCTestCase { creationDate: Date(), mediaType: .image, pixelWidth: 1024, pixelHeight: 768, - duration: 0, isScreenshot: false, isRAW: false + duration: 0, isScreenshot: false, isRAW: false, isScreenRecording: false ) } } @@ -35,15 +35,13 @@ final class BackupEngineTests: XCTestCase { XCTAssertEqual(mock.uploadCallCount, 3) } - func test_backup_skipsExistingFiles_whenNewFilesOnly() async throws { + func test_backup_skipsExistingFiles_alwaysHardcoded() 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) + let result = try await engine.run(connection: makeConnection(), filter: BackupFilter()) XCTAssertEqual(result.skippedCount, 2) XCTAssertEqual(result.uploadedCount, 0) diff --git a/Tests/MockNASService.swift b/Tests/MockNASService.swift index f23a7f3..a61e67a 100644 --- a/Tests/MockNASService.swift +++ b/Tests/MockNASService.swift @@ -34,6 +34,16 @@ final class MockNASService: NASTransferProtocol { func fileExists(at remotePath: String) async throws -> Bool { fileExistsAnswer } + func downloadData(at remotePath: String) async throws -> Data { Data() } + + func writeData(_ data: Data, to remotePath: String) async throws { + if shouldFail { throw MockError.uploadFailed } + } + + func deleteFile(at remotePath: String) async throws { + if shouldFail { throw MockError.uploadFailed } + } + 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))