Previous logic capped alreadySafe at phoneTotal immediately when the last
reconcile showed everything safe, freezing both counters at 0 pending /
max safe even while uploads were running.
Now during an active session both counters are driven by engine.job:
Pending = totalFiles - (uploaded + skipped + failed) → counts down
Safe = phoneTotal - Pending → counts up
Ensures Safe + Pending == phoneTotal at all times and the numbers visibly
move as uploads complete, regardless of the snapshot state.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Replace the sequential for-loop with withTaskGroup running N concurrent
uploads. Each slot gets its own SMB/SFTP connection so uploads never
block each other. Default concurrency is 3 (configurable via
ConnectionStore.maxConcurrentUploads, capped at 6).
- ConnectionStore: add maxConcurrentUploads (default 3, persisted)
- BackupEngine: build pool of N connections at start, seed TaskGroup
with one task per slot, recycle slots as tasks complete
- Manifest checkpoints, pause, cancel, retry logic all preserved
- SlotResult extracted to BackupEngine extension for Sendable conformance
- Fix: remove undefined .trackScrollForTabBar() from BackupView
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Orange on "0 Pending" implied an active warning state when everything was
already backed up. Now dims to inkQuaternary at 0, returns to orange when
there are actually pending items.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Page 0 statsStrip has padding(.top,4) from ScrollView + padding(.vertical,4)
on the HStack = 8pt before the numbers. Page 1 had none, placing text 8pt
higher. Adding padding(.top,8) to page1BelowContent corrects the alignment.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
The Spacer(minLength:16).frame(maxHeight:40) between brand and ring was
expanding to ~40pt on pages 1/2 (no competing ScrollView) but collapsing
to ~16pt on page 0 (ScrollView takes flexible space), shifting the ring
and stats strip down by up to 24pt on storage/cleanup pages. Fixed height.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Replace opacity(0) approach with a ZStack(.frame(height:66)) container.
ZStack is empty on pages 1/2 but still allocates exactly 66pt, so the
page indicator and stats strip sit at the same absolute Y on every page.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Replace the hard-coded Color.clear(height:60) placeholder with the real
cascadingArrows + circleStatusRow views set to opacity(0). Both are always
laid out so they reserve the identical height on every page, guaranteeing
the stats strip sits at the same Y coordinate regardless of active page.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Backed-up photos row in clean sheet now toggles on/off like other rows.
Page 1 storage stats (USED/FREE/JUNK) pushed to bottom of screen via Spacer,
matching the alignment of the stats strip on the main backup page.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Pages 1 and 2 both show PULL/RELEASE TO CLEAN. Only page 0
triggers backup; all others trigger the clean confirmation.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Moved pullGesture and pageSwitchGesture from the ring ZStack to
the outermost ZStack via simultaneousGesture. Both gestures now
fire from anywhere on the screen without blocking scroll.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Applies to both compactStat (NAS/iPhone/Pending/Safe) and
storageStat (USED/FREE/JUNK).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Removed gray card background/clipShape. storageStat now uses
30pt light monospacedDigit with statDividers, identical to
compactStat on page 0. Consistent design across all pages.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
36pt semibold rounded → 36pt light monospacedDigit, matching
the thin numeral design language used across all widgets.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Semibold rounded was wrong. Values are now 30pt light matching
the thin numeral style shown in the design screenshot.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Pull zone now shown on pages 0 and 2. Labels route to
PULL/RELEASE TO CLEAN on page 2. Releasing triggers
triggerCleanViaPull() which fires the clean confirmation dialog
with the same ripple effect as the backup pull.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
TimelineView drives a 2.4s cycle: two sin-bell chevron pulses
(primary + ghost trailing 7pt below) then a pause. Mirrors the
cascadingArrows pattern — suggests pull-down on all three pages.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
All pages now share circleSection. Page 2 shows a green arc
(freeable/backed-up fractions) with a leaf icon and GB FREEABLE
label at center, matching the anchored position of pages 0 and 1.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Three amber/gray capsule pills show the current page; a small
rounded-square button with an amber border advances to the next page.
Positioned below the status message (between heroView and the scroll
content), replacing the old left/right chevron bar at the bottom.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
9 files were present on disk but not referenced by NASBackup.xcodeproj,
causing 18 "cannot find type in scope" errors. Added to their respective
groups (Services, Models, Browse, Components) in the main app target.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Pages 0 (backup) and 1 (storage) now share a single KisaniRingView
instance. Arc values (progress, color) swap in-place via computed
properties so the ring never re-mounts or shifts position during
horizontal swipes. Page 2 (cleanup) retains its own hero layout.
Pull gesture and page-switch gesture coexist via simultaneousGesture.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Pull zone is now an .overlay(alignment: .top) offset upward by its own
height, so it appears above the ring without pushing it down. Removed the
ring's offset(y:) so it stays perfectly fixed while pulling.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
BackupView:
- Move circle hero outside ScrollView so DragGesture no longer
competes with the ScrollView's pull-to-refresh capture
- Pull-to-refresh (native iOS gesture) now starts/resumes backup
instead of only refreshing status
- Ring diameter 240 → 270pt, ripple circle 258 → 288pt
- Restructure: heroView (fixed) + ScrollView(belowContent) + pageNav
- page1/page2 content split into heroView + belowContent dispatch
BackupStatusService:
- refreshAfterBackup now bumps nasArchiveTotal by newlyOnNAS count
(fileSize > 0 entries only), preventing the NAS stat from
flickering back to the pre-backup value while writeManifest runs
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- Replace paginated ring center with full WidgetState machine (offline/checking/pending/running/safe/paused)
- Pull-down gesture on circle triggers backup with haptic + ripple animation
- Cascading TimelineView arrows, status dot pulse, live sub-label
- Add innerColor param to KisaniRingView for per-state color transitions
- Add pageSwitchGesture for horizontal swipe on pages 1 & 2
- Remove all action buttons; pull gesture is the sole backup trigger
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Adds a blue-tinted shadow glow to the outer NAS progress arc for
visibility. Darkens track circles (0.92 → 0.76) and the middle
static ring (0.78 → 0.62). Bumps outer arc lineWidth to 1.5.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Translates the React KisaniCircle design to SwiftUI: three concentric
thin rings (outer slate progress, middle static gray, inner green
progress) replace the old single ProgressRing. All ring center text
updated to ultraLight 46pt numbers with 8pt monospaced uppercase labels.
Adds ringCheckmark (22px circle + tiny checkmark) for completed/all-safe
states. Updates checking, disconnected, failed center views to match.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Replace ZStack-based cell with Color.clear.aspectRatio(1,.fit) + .overlay.
The overlay constrains all children (image, video badge, sync dot) to the
exact square frame, so scaledToFill cannot inflate the cell height in
LazyVGrid. Previously, the ZStack let the filled image push beyond the
aspectRatio constraint, causing inconsistent row heights.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
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>
- NASTransferProtocol: add deleteFile(at:) to enable server-side deletion
- SFTPService: implement deleteFile using Citadel sftp.remove(at:)
- SMBService: implement deleteFile using SMBClient deleteFile(path:)
- SyncView FREE UP SPACE: two new cards —
• "Clean up screenshots" — shows count of phone screenshots already
backed up to NAS, opens review sheet for bulk PHPhotoLibrary deletion
• "NAS screenshot quarantine" — identifies NAS files that match phone
screenshots, offers to move them into a Screenshots/ subfolder via
download → writeData → deleteFile per file
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Phase 1 was only checking the Kisani manifest for sync status. Phone photos
uploaded by other tools had no manifest entry, showed as orange (phone-only)
even though they were already on the NAS.
Now Phase 1 falls back to a filename lookup against the full NAS directory
listing: manifest match → green (.synced), NAS directory match → green (.synced),
no match → orange (.phone). Phase 3 skips filenames already resolved in Phase 1.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Gallery NAS was only showing Kisani-manifest-tracked files (2 of 7432). Now
fetches the full NAS directory listing on load and adds Phase 3 items for files
not in the manifest (uploaded by other tools) — all 7432 files appear in the
gallery with thumbnails loaded lazily on scroll.
Settings network section: "No Wi-Fi" → "Wi-Fi" when connected but SSID is nil
(no location permission). Added cellular status row below Wi-Fi row.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
The root cause: buildManifestIndex was discarding manifest localIdentifiers and
switching to pure filename-only matching whenever dirCount > manifest.totalCount
(which is always true when the NAS has files from other tools). If filenames didn't
match exactly, Already Safe dropped to 0 on every stale-cache reconcile.
Fix: introduce ManifestIndex(manifest:nasFilenames:totalCount:) — a hybrid that keeps
Kisani's localIdentifiers as primary keys AND adds all NAS directory filenames for
filename-fallback. Used in:
- buildManifestIndex (full reconcile)
- fast path (when dirCount > cached.entries.count, previously gated at <= 1)
- BackupEngine step 3b (so "Back up again" only uploads genuinely missing files)
Also simplify countSafe / pendingIDs / step-4 filter to always try filename fallback
(removes the isFilenameOnly gate that blocked matches when the index had IDs).
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Use UIColor.secondarySystemGroupedBackground / tertiarySystemFill so
the segmented control adapts correctly to light and dark mode; darken
the shadow in dark mode so the selected pill is still visible.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- Adds cellular row (antenna icon) below Wi-Fi in connection status section;
orange when active, muted when not in use
- Replaces "Retry Failed" header button in errors section with "Clear Errors"
(destructive, calls clearErrorHistory); retry action retained in error detail sheet
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Screen recordings are videos captured by iOS screen recording and are
almost never wanted in a photo backup. They are now excluded from all
counts and backup queues unless explicitly enabled.
Changes:
- BackupFilter.includeScreenRecordings (default: false) added
- PHAssetMediaSubtype.videoScreenRecording predicate applied in both
fetchAssets() and fetchResultForReconciliation() — excluded at the
PHFetchRequest level before enumeration
- LocalPhotoIndex.Record.isScreenRecording populated via makeRecord()
so the fast-path countSafe/count also respects the toggle
- LocalPhotoIndex.passes() checks includeScreenRecordings
- Settings "Screen Recordings" toggle added below Screenshots
This also resolves the transient count flash: screen recordings were
being included in the "need backup" tally on the initial fast-path
reconcile, then dropping when the filter was fully applied.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>