Remove newFilesOnly toggle; add functional notifications; fix WiFi status
WHAT TO BACK UP section: - Remove "New files only" / "Skip files already on NAS" toggle from Settings - Remove BackupFilter.newFilesOnly field — duplicate protection is always active (fileExists pre-check + hybrid filterIndex ensure NAS files are never re-uploaded) NOTIFICATIONS section: - Replace static decorative rows with real ToggleRow controls backed by ConnectionStore (notifyOnStart=false, notifyOnComplete=true, notifyOnErrors=true) - BackupEngine now gates notification calls behind these store flags NETWORK section: - WiFi row always shows ● On / ● Off indicator (was only shown when SSID known) - Shows "Trusted" / "Unknown" when SSID is available, "On" / "Off" otherwise Tests: - makeAssets: add missing isScreenRecording parameter - Rename test_backup_skipsExistingFiles_whenNewFilesOnly → _alwaysHardcoded, remove filter.newFilesOnly assignment - MockNASService: add downloadData, writeData, deleteFile to satisfy protocol Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user