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 includeVideos: Bool = true
|
||||||
var includeScreenshots: Bool = false
|
var includeScreenshots: Bool = false
|
||||||
var includeRAW: Bool = false
|
var includeRAW: Bool = false
|
||||||
var newFilesOnly: Bool = true
|
|
||||||
var includeScreenRecordings: Bool = false
|
var includeScreenRecordings: Bool = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,10 +138,6 @@ struct SettingsView: View {
|
|||||||
hairline
|
hairline
|
||||||
ToggleRow(icon: "rays", title: "RAW",
|
ToggleRow(icon: "rays", title: "RAW",
|
||||||
isOn: $store.backupFilter.includeRAW)
|
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)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
@@ -221,15 +217,19 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(AppTheme.inkTertiary)
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if lanMonitor.currentSSID != nil {
|
HStack(spacing: 4) {
|
||||||
let trusted = store.trustedSSIDs.contains(lanMonitor.currentSSID ?? "")
|
Circle()
|
||||||
HStack(spacing: 4) {
|
.fill(lanMonitor.isOnNetwork ? AppTheme.positive : AppTheme.inkQuaternary)
|
||||||
Circle()
|
.frame(width: 5, height: 5)
|
||||||
.fill(trusted ? AppTheme.positive : AppTheme.inkQuaternary)
|
if let ssid = lanMonitor.currentSSID {
|
||||||
.frame(width: 5, height: 5)
|
let trusted = store.trustedSSIDs.contains(ssid)
|
||||||
Text(trusted ? "Trusted" : "Unknown")
|
Text(trusted ? "Trusted" : "Unknown")
|
||||||
.font(AppTheme.micro())
|
.font(AppTheme.micro())
|
||||||
.foregroundStyle(trusted ? AppTheme.positive : AppTheme.inkTertiary)
|
.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")
|
sectionLabel("NOTIFICATIONS")
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
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
|
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
|
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)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.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 {
|
private var hairline: some View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(AppTheme.separator)
|
.fill(AppTheme.separator)
|
||||||
|
|||||||
@@ -185,7 +185,9 @@ final class BackupEngine: ObservableObject {
|
|||||||
|
|
||||||
// ── 5. Upload loop ─────────────────────────────────────────────────
|
// ── 5. Upload loop ─────────────────────────────────────────────────
|
||||||
job.start(totalFiles: pendingAssets.count, totalBytes: 0)
|
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
|
// Build initial queue (all queued) for SyncView
|
||||||
queueItems = pendingAssets.map { asset in
|
queueItems = pendingAssets.map { asset in
|
||||||
@@ -433,8 +435,10 @@ final class BackupEngine: ObservableObject {
|
|||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
if result.failedCount > 0 {
|
if result.failedCount > 0 {
|
||||||
NotificationService.notifyBackupFailed(count: result.failedCount)
|
if store.notifyOnErrors {
|
||||||
} else {
|
NotificationService.notifyBackupFailed(count: result.failedCount)
|
||||||
|
}
|
||||||
|
} else if store.notifyOnComplete {
|
||||||
NotificationService.notifyBackupCompleted(
|
NotificationService.notifyBackupCompleted(
|
||||||
uploaded: result.uploadedCount,
|
uploaded: result.uploadedCount,
|
||||||
total: BackupStatusService.shared.snapshot.phoneTotal
|
total: BackupStatusService.shared.snapshot.phoneTotal
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ final class ConnectionStore: ObservableObject {
|
|||||||
didSet { UserDefaults.standard.set(localRetentionDays, forKey: "localRetentionDays") }
|
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)
|
// Auto-backup via LAN trigger (trusted Wi-Fi join)
|
||||||
@Published var autoBackupEnabled: Bool {
|
@Published var autoBackupEnabled: Bool {
|
||||||
didSet { UserDefaults.standard.set(autoBackupEnabled, forKey: "autoBackupEnabled") }
|
didSet { UserDefaults.standard.set(autoBackupEnabled, forKey: "autoBackupEnabled") }
|
||||||
@@ -99,6 +110,9 @@ final class ConnectionStore: ObservableObject {
|
|||||||
onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown")
|
onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown")
|
||||||
appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system
|
appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system
|
||||||
localRetentionDays = UserDefaults.standard.object(forKey: "localRetentionDays") as? Int ?? 0
|
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
|
autoBackupEnabled = UserDefaults.standard.object(forKey: "autoBackupEnabled") as? Bool ?? true
|
||||||
autoBackupOnOpen = UserDefaults.standard.object(forKey: "autoBackupOnOpen") as? Bool ?? false
|
autoBackupOnOpen = UserDefaults.standard.object(forKey: "autoBackupOnOpen") as? Bool ?? false
|
||||||
allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false
|
allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ final class BackupEngineTests: XCTestCase {
|
|||||||
creationDate: Date(),
|
creationDate: Date(),
|
||||||
mediaType: .image,
|
mediaType: .image,
|
||||||
pixelWidth: 1024, pixelHeight: 768,
|
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)
|
XCTAssertEqual(mock.uploadCallCount, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_backup_skipsExistingFiles_whenNewFilesOnly() async throws {
|
func test_backup_skipsExistingFiles_alwaysHardcoded() async throws {
|
||||||
let mock = MockNASService()
|
let mock = MockNASService()
|
||||||
mock.fileExistsAnswer = true
|
mock.fileExistsAnswer = true
|
||||||
let photos = MockPhotoLibraryService(assets: makeAssets(count: 2))
|
let photos = MockPhotoLibraryService(assets: makeAssets(count: 2))
|
||||||
let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
|
let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
|
||||||
|
|
||||||
var filter = BackupFilter()
|
let result = try await engine.run(connection: makeConnection(), filter: BackupFilter())
|
||||||
filter.newFilesOnly = true
|
|
||||||
let result = try await engine.run(connection: makeConnection(), filter: filter)
|
|
||||||
|
|
||||||
XCTAssertEqual(result.skippedCount, 2)
|
XCTAssertEqual(result.skippedCount, 2)
|
||||||
XCTAssertEqual(result.uploadedCount, 0)
|
XCTAssertEqual(result.uploadedCount, 0)
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ final class MockNASService: NASTransferProtocol {
|
|||||||
|
|
||||||
func fileExists(at remotePath: String) async throws -> Bool { fileExistsAnswer }
|
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 {
|
func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws {
|
||||||
if uploadDelay > 0 {
|
if uploadDelay > 0 {
|
||||||
try await Task.sleep(nanoseconds: UInt64(uploadDelay * 1_000_000_000))
|
try await Task.sleep(nanoseconds: UInt64(uploadDelay * 1_000_000_000))
|
||||||
|
|||||||
Reference in New Issue
Block a user