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>
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>
- buildManifestIndex now always scans the NAS directory on every full reconcile
(removed the `totalCount <= 1` guard) so NAS Archive count updates accurately
on every manual refresh, not just when the manifest is new/sparse
- The dir > manifest check still switches to filename-based matching when the
directory has more files than Kisani's manifest (uploaded by other tools)
- checkStatusRow shows NAS connection status (orange dot + "NAS Connected" /
red dot + "NAS Offline") below the timestamp line
- BackupFilter now conforms to Equatable; BackupView re-reconciles immediately
when the screenshot/video/RAW filter toggles change
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- Move "X sec ago / refresh" row from inside statsStrip to directly below the
ring hero so the last-checked time is visible near the status it describes
- Remove "Checking status…" spinner from the action button — button shows
"Start backup" / "Back up again" at all times
- Remove "Checking…" spinner from the ring center (page 0) — ring always
shows the pending backup count or "All photos safe"
- Re-enable autoBackupEnabled default (true) now that already-safe counting
is accurate; auto backup will fire on LAN join when items need backup
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
BackupEngine: after all retries fail, call fileExists() before recording a
failure. If the file is on NAS (uploaded earlier but fileExists silently
threw on the pre-check), count it as skipped and add it to manifestEntries —
this ensures writeManifest includes every safe asset and prevents alreadySafe
from dropping to 1 after the post-backup reconcile.
BackupView: remove the immediate refreshAndWait from onChange(.failed). That
call raced with refreshAfterBackup's writeManifest, cancelled the manifest-
correct refresh, then reconciled against a stale 1-entry NAS manifest.
Auto-resolve (.failed → .completed when needBackup == 0) is now owned by the
coordinator so it runs after writeManifest finishes.
AutoBackupCoordinator: add backupTriggerArmed flag (set by onActive/
handleNASReachable, cleared by handleReconciliationComplete). Post-backup
reconciles from refreshAfterBackup are no longer able to re-trigger auto-
backup — only an explicit app-open or LAN-join can arm the trigger. Also
added auto-resolve: calls engine.resolveSuccess() when job is .failed but
needBackup == 0, which fires the .completed green-flash path in BackupView.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
BackupEngine — manifest loading never poisons NASManifestCache:
Before: if the NAS manifest download failed for any reason,
BackupManifest() (0 entries) was written to NASManifestCache.
Every subsequent fast-path reconcile read the poisoned cache and
set nasArchiveTotal = 0, making every asset appear as needing backup.
Checkpoint writes then set the cache to 1 entry (first upload), which
is why NAS Archive flipped from 7422 to 1 when starting a backup.
After: NAS > cache > empty. If NAS download fails or returns an empty
manifest, the existing cache is used as the authoritative base and is
never overwritten. Only a non-empty NAS manifest replaces the cache.
BackupView — live counters stay on persistent totals:
- nasArchiveDisplayCount: snapshot.nasArchiveTotal + uploadedFiles during
active backup so the NAS Archive card grows as uploads are confirmed
instead of jumping to the session count
- alreadySafeDisplayCount: adds both uploadedFiles AND skippedFiles —
skipped means the file was already on NAS (fileExists=true) but not
yet in the manifest, so it is safe and should be counted immediately
- notBackedUpDisplayCount already derived from alreadySafeDisplayCount
so it correctly shrinks as uploads and skips are confirmed
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- BackupView: action button shows "Checking status…" with spinner and blocks
taps while coordinator.phase == .checking — prevents starting a backup
before reconciliation has determined what actually needs uploading
- BackupView: NAS status row shows statusService.refreshError (actual SMB
error reason: auth failure, timeout, host unreachable) instead of the
generic "Unreachable" string — surfaces the real diagnosis to the user
- BackupStatusService: invariant assertion after every full reconcile — logs
.error if alreadySafe + needBackup ≠ phoneTotal so filter mismatches and
off-by-ones surface immediately in Console.app
- BackupStatusService.buildManifestIndex: full diagnostic logging — remotePath,
manifest file path, download byte count, decoded entry count, bootstrap
fallback reason — all visible in Console.app filtered by "BackupStatus"
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task
and scenePhase triggers (navigation appear, rapid foreground cycles) are
no-ops; start a 60s periodic recheck timer on first active call; stop timer
and reset debounce on willResignActiveNotification so each new foreground
session always gets an immediate fresh status check
- BackupView: remove the redundant lanMonitor.nasReachable onChange that was
calling statusService.refresh() directly — coordinator already handles this
via its Combine $nasReachable subscriber, avoiding a double reconcile that
could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value
- BackupStatusService: when NAS connect fails, restore nasArchiveTotal from
NASManifestCache before writing the offline snapshot — prevents the count
from showing 0 when the NAS is temporarily unreachable but the cache is warm
- BackupStatusService: manifest validity check — if the decoded manifest has
≤ 1 entries (corrupt or newly created), scan the NAS directory and use the
real file count as the display floor for nasArchiveTotal without adding
orphan entries to the manifest (that was the 7421 regression)
- BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for
the validity check path — keeps localIdentifier matching intact while
correcting the displayed archive count
- SMBService: add os.log around connect/auth/listDirectory with actual error
reason instead of always surfacing authenticationFailed; distinguish network
errors (timeout, host unreachable) from auth errors in thrown BackupError
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
AutoBackupCoordinator (new):
- @MainActor singleton that owns all auto-backup decisions
- Phase model: idle / checking / autoBackingUp / disconnected
- idle: nothing pending or destination not set
- checking: reconciliation in progress before decision
- autoBackingUp: coordinator-triggered BackupEngine run active
- disconnected: NAS offline, pending count queued for retry
- Combine subscription on BackupStatusService.$isRefreshing fires
handleReconciliationComplete() on every reconcile completion — the
single entry point for all auto-backup decisions
- Combine subscription on LANMonitor.$nasReachable fires
handleNASReachable() which retries only when phase == .disconnected
- Guards: autoBackupEnabled, quiet hours, chargingOnlyMode + battery state
- canAutoBackup also blocks when engine.job.status.isActive to prevent
double-start from concurrent photo library change + foreground events
BackupView:
- .task + .onChange(of: scenePhase) now call coordinator.onActive() instead
of statusService.refresh() directly — coordinator decides whether to also
start a backup after reconciliation completes
- ringContentID includes coordinator phase suffix → ring animates on
checking/disconnected transitions
- ringCenterContent case 0: when engine is idle, shows:
- disconnectedRingView if phase == .disconnected and needBackup > 0
(wifi.slash icon, "Waiting for NAS", queued count)
- checkingRingView if phase == .checking (spinner, "Checking…")
- existing ringMainView otherwise
- ringColor: dim orange (0.35 opacity) when disconnected with pending items
- Pull-to-refresh reconciliation triggers auto-backup via Combine chain
ConnectionStore:
- autoBackupEnabled: Bool, default true, UserDefaults-persisted
SettingsView:
- AUTO BACKUP section at top with autoBackupEnabled toggle
BackgroundTaskManager:
- BGProcessingTask: quiet-hours guard, requiresExternalPower mirrors
chargingOnlyMode, expiration handler calls engine.cancel() for clean exit
- BGAppRefreshTask: lightweight refresh only (no upload in background refresh)
- Both tasks reschedule themselves before doing any work
AppDelegate:
- UIDevice.current.isBatteryMonitoringEnabled = true for battery checks
NASBackupApp:
- AutoBackupCoordinator.shared injected as @StateObject + environmentObject
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- BackupEngine: pre-filter assets against ManifestIndex before job.start() so
totalFiles reflects only pending items (not full gallery). Short-circuit with
allAlreadySafe() when queue is empty.
- BackupJob: add resolveSuccess() (upgrades stale .failed → .completed after
reconciliation proves needBackup==0) and allAlreadySafe() for empty-queue case.
- BackupStatusService: expose refreshAndWait(force:) async for pull-to-refresh
and post-failure reconciliation.
- BackupView: wire resolvedStatus throughout ringContentID, ringMainView,
ringColor, and actionButton; fix progress label to show pending-queue
denominator; handle .failed in onChange by reconciling and auto-resolving
to .completed when counts confirm all photos are safe.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
BackupJob: fix finish() — failedCount > 0 now correctly sets .failed
instead of .completed in both branches.
BackupEngine: replace filename-only fileExists check with a 3-layer
dedup strategy. For each asset: (1) check ManifestIndex by localIdentifier
O(1); (2) filename fallback for bootstrap manifests; (3) NAS fileExists
for files not yet in manifest. Never overwrite an existing NAS file.
Manifest index is built once after connect and held in a local let for
the loop — Data+BackupManifest are released immediately after.
BackupView — ring color:
active (running/preparing/paused) → orange always
failed → destructive
needBackup == 0 && phoneTotal > 0 → green
default → neutral gray
Ring is no longer permanently green after .completed.
BackupView — CTA button:
Default color is always black (AppTheme.ink).
On .completed: briefly flashes green for 2.5 s then smoothly
reverts to black. ctaFlashGreen state drives the color.
BackupView — Already Safe invariant:
alreadySafeDisplayCount = min(base + uploadedFiles, phoneTotal)
during active backup; min(base, phoneTotal) otherwise.
Adding skippedFiles was removed — they are already counted in
snapshot.alreadySafe from the last reconcile.
notBackedUpDisplayCount is derived from alreadySafeDisplayCount to
keep the invariant alreadySafe + needBackup == phoneTotal.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Replace the SF Symbol server.rack with the custom KisaniLogoMark
extracted into a shared component used by both ConnectView and BackupView.
Introduce ConnectStep enum (credentials → folder → ready) in
ConnectViewModel. The primary CTA changes label and colour at each
step: "Connect" → "Select Destination" → "Get Started". The folder
row animates in only after successful authentication; its border goes
green once a valid path is chosen. Status message updates live with
the current step. Submitting the password field triggers verify().
Rename project.yml name to Kisani so xcodegen produces Kisani.xcodeproj.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
foregroundStyle(_:) on Text with a ShapeStyle requires iOS 17;
replace with foregroundColor for the two Text-concatenation nodes
in the refresh status row to stay compatible with the iOS 16 target.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- Wi-Fi row: title always "Wi-Fi", subtitle shows SSID or Cellular/Not connected;
badge is Connected (green) / Cellular (amber) / Offline (muted) — no more
contradictory "No Wi-Fi" title with "Wi-Fi connected" subtitle
- NAS badge: Reachable now amber (TCP ping only, not auth-confirmed), Offline red,
Unknown muted — removes misleading green on unverified NAS connectivity
- Error rows are now tappable; chevron affordance + ScaleButtonStyle; opens detail
sheet with date, counts, duration, NAS host, trigger type, and suggested fix steps
- Sync Activity section: Clear button with confirmation alert (warns about error count
if errors exist), calls store.clearHistory()
- BackupViewModel: compareWithNAS() computes accurate pre-scan alreadySafeCount /
notBackedUpCount by matching lowercased filenames against NAS directory listing
- BackupView: ring shows state-based content (idle: not-backed-up count, running: %,
completed: checkmark), pill-style page indicator, display counts update live during
backup run, loadNASAndCompare() called on appear
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>