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>
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>
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>
- 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>
- LocalPhotoIndex: add count(filter:) so fast-path reconcile uses the
filtered asset count as phoneTotal instead of the unfiltered totalCount;
fixes the broken alreadySafe + needBackup == phoneTotal invariant
- BackupStatusService: fast-path uses count(filter:) instead of totalCount;
writeManifest falls back to NASManifestCache when NAS download fails so
accumulated history is never silently discarded on a flaky connection
- ConnectionStore: autoBackupEnabled defaults false; new autoBackupOnOpen
(default false) separates LAN-join auto-backup from app-open auto-backup
- AutoBackupCoordinator: onActive(lanTriggered:) tracks trigger source;
handleReconciliationComplete gates on autoBackupEnabled for LAN joins and
autoBackupOnOpen for app-open — prevents backup starting on every launch
- SettingsView: split auto-backup into two toggles with accurate descriptions
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
GalleryViewModel.load() now has a three-tier resolution for manifest entries:
1. statusService.lastManifest — in-memory, freshest
2. NASManifestCache disk cache — survives restarts
3. Direct NAS download — fallback when both caches are cold (first launch after install,
or before BackupStatusService reconcile has completed)
The direct download also populates NASManifestCache so subsequent opens are instant.
Also adds GalleryViewModel.bind(to:connection:) which subscribes to
statusService.$lastManifest and automatically reloads gallery items when
BackupStatusService finishes reconciling — gallery updates without manual pull-to-refresh.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).
Fast path reconcile:
When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
entirely and returns in microseconds. Falls through to full NAS path only when stale.
AutoBackupCoordinator.onActive():
Changed force: true → force: false so the 30-second debounce prevents expensive rescans
on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.
BackupQueueItem + queue tracking:
BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
always has data to show even after restart.
NotificationService:
Centralised local notification sender replacing the inline UNMutableNotificationContent
blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
identifiers so same-type notifications replace each other.
SyncView redesign:
- Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
- No live NAS directory listing (was expensive NAS connection on every tab open)
- Shows engine.queueItems grouped into Active / Failed / Completed sections
- Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
- Idle state shows archive count from statusService snapshot
GalleryViewModel:
NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.
AppDelegate:
Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).
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>
Introduce ConnectionState enum (idle → authenticating → authenticated
→ destinationSelected → failed) replacing the old Bool isConnected flag.
The entire UI renders reactively from this single state value.
Authentication: vm.authenticate() drives the state, haptic feedback
fires on success, any field edit resets back to idle so the user always
knows their current auth is stale after editing.
Connected banner: slides in below the credentials card after auth
showing "Connected to [host]" with a green dot and a Disconnect button
that resets state without clearing credentials.
Destination row: always visible at 40% opacity with a lock icon and
placeholder text "Connect to your NAS first" — activates to full opacity
with chevron and browsable state after auth. Green border + subtitle
"Backup destination ready" appears after selection. Taps blocked via
allowsHitTesting until authenticated.
CTA progression:
idle/failed → "Connect" (enabled when fields filled)
authenticating → spinner, disabled
authenticated → "Choose Destination" (opens folder browser)
destinationSelected → "Get Started" in green → navigates to dashboard
After folder browser dismissal, confirmDestination() writes the path
to ConnectionStore and fires a light haptic. Navigation to MainTabView
happens only when state == .destinationSelected.
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Remove slide-in animation — destination row is always visible but
dimmed (opacity 0.45, taps blocked) until credentials are verified.
On successful auth it becomes fully active, green border appears
once a valid path is chosen.
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>
Section order: Connection Status → Current Session (live, when active) →
Errors → Last Successful Backup → Sync Activity → History → About
Connection Status:
- NAS row shows remotePath as third subtitle line (destination trust signal)
- Wi-Fi Offline badge now orange (was muted grey)
Current Session (new):
- Shows only when backup is active (preparing/running/paused)
- Progress bar, file count, live upload speed, ETA, current filename
- Badge colors: blue for uploading, orange for paused/scanning
Last Successful Backup (new):
- Trust signal always visible — shows date + total photos safe
- Empty state if no clean backup yet
Errors section:
- "Retry Failed" button in header (disabled while active or NAS unreachable)
- Error rows show count + uploaded count as context
- Error detail sheet: Retry Failed Uploads button disabled with reason when NAS is down
Sync Activity:
- Clear button opens confirmationDialog: Clear Completed Logs / Clear Errors / Clear Everything
- Labels: "Backups completed" / "New uploads" / "Already safe" / "Errors"
- ConnectionStore: clearCompletedHistory() and clearErrorHistory() added
History rows:
- "skipped" replaced with human-readable text:
"X already backed up", "X new · Y already safe", "X photos backed up"
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
- NAS reachable → green "Connected" (TCP ping = usable, not a warning state)
- NAS offline → orange "Offline" (matches color rule: orange = not reachable)
- NAS icon changed from externaldrive to server.rack
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>
- safeAreaInset pins Connect/Continue button to screen bottom,
eliminating dead space below the form
- UGreenCompatibilityBadge now takes selectedProtocol param:
green dot + green SMB label when SMB selected,
grey dot + grey SFTP label when SFTP selected
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
Light mode: white disk icon on black bg
Dark mode: black disk icon on white bg
No green — uses AppTheme.ink + AppTheme.inkInverse throughout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SF Symbol 'externaldrive.fill' renders reliably vs sparse PNG.
White icon on UGREEN green (#1FA462) background — crisp at all sizes.
kisani. weight: medium -> bold on login and connect screens.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The ugreen_logo.png is a black outline on transparent background.
Rendering it as .template (white) over UGREEN brand green (#1FA462)
makes it visible and on-brand on all three screens:
- LoginView: 90pt logo below kisani. wordmark
- ConnectView: 72pt logo replaces old Kisani title
- BackupView toolbar: 22pt chip with matching treatment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>