Commit Graph

42 Commits

Author SHA1 Message Date
Robin Kutesa
d5b1f13434 Settings: fix appearance picker in dark mode
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>
2026-05-18 23:54:34 +03:00
Robin Kutesa
777024e9c7 Activity menu: add cellular row, replace Retry header with Clear Errors
- 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>
2026-05-18 23:42:43 +03:00
Robin Kutesa
9cb1bc29ca feat: add screen recordings filter, exclude by default
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>
2026-05-18 23:33:47 +03:00
Robin Kutesa
6c38893374 fix: always refresh NAS dir count, show connection status, fix screenshot filter reactivity
- 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>
2026-05-18 23:15:51 +03:00
Robin Kutesa
78a6b377f2 feat: move check status below ring, remove checking spinner, re-enable auto-backup
- 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>
2026-05-18 21:12:35 +03:00
Robin Kutesa
8d8b6c4418 fix: resolve duplicate-file failures, alreadySafe reset, and backup restart loop
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>
2026-05-18 18:55:27 +03:00
Robin Kutesa
00e4ecc506 Fix NAS Archive reset and Already Safe regression
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>
2026-05-18 17:24:56 +03:00
Robin Kutesa
ae22d7a739 Finish backup spec: button state, invariant assertion, NAS error surfacing
- 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>
2026-05-18 16:56:16 +03:00
Robin Kutesa
d2190bce8a Fix backup loop, NAS Archive regression, and manifest validity check
- 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>
2026-05-18 16:07:41 +03:00
Robin Kutesa
3beea88fab Fix backup loop, phone count invariant, and auto-start behaviour
- 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>
2026-05-18 10:01:09 +03:00
Robin Kutesa
a98822d7b2 fix: NAS gallery shows backed-up files even when caches are cold
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>
2026-05-17 23:40:18 +03:00
Robin Kutesa
1afcbaff96 perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
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>
2026-05-17 23:28:29 +03:00
Robin Kutesa
025326f2b2 feat: redesign Gallery as photo timeline with sync state, deduplication, and detail view
- GalleryItem: unified model with phone/nas/synced source, sync state enum
- GalleryViewModel: off-actor PHFetchResult + manifest merge, Phone/NAS/All tab filtering, date section grouping, Combine-driven reactivity
- GalleryView: sticky date section headers, 3-col grid, shimmer placeholders, sync dots (green/orange/blue), video duration badge, search bar, context menu (Share, Save to Photos, Delete from Device)
- GalleryDetailView: full-screen swipeable photo viewer (TabView page mode), pinch-to-zoom, double-tap zoom toggle, bottom metadata sheet with file info and actions
- BackupStatusService: expose lastManifest for gallery deduplication without extra NAS connection
- ThumbnailCache: add decodeOffThread static helper for off-actor JPEG decode
- BackupManifest: Sendable conformance for safe Task.detached capture
- ThumbnailCache: synced items load from PHImageManager (instant) instead of NAS

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 21:30:31 +03:00
Robin Kutesa
5b68fde62f feat: automatic backup on launch, foreground, and photo library change
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>
2026-05-17 17:28:11 +03:00
Robin Kutesa
f40cf9ee37 Fix backup progress state machine: pending queue, resolvedStatus, failure resolution
- 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>
2026-05-17 15:58:54 +03:00
Robin Kutesa
819f2b949e Fix backup state logic, ring colors, Already Safe invariant, and dedup
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>
2026-05-17 15:30:52 +03:00
Robin Kutesa
c11ce6d644 Overhaul Connect flow with proper state machine and progressive UX
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>
2026-05-17 15:03:12 +03:00
Robin Kutesa
44f290da54 Show destination folder row always, activate after auth
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>
2026-05-17 14:08:37 +03:00
Robin Kutesa
00f68eebcd Redesign Connect flow with step-based UX and matching logo
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>
2026-05-17 13:58:03 +03:00
Robin Kutesa
97dfb62dc7 Fix foregroundStyle iOS 16 compatibility in BackupView
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>
2026-05-17 13:17:13 +03:00
Robin Kutesa
712e110f57 Add live backup status reconciliation with NAS manifest
BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver):
- Loads cached BackupStatusSnapshot instantly from UserDefaults on init
- Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare
  → enforce invariant (alreadySafe ≤ phoneTotal), persist result
- Debounced (30s) for photo library changes; force=true bypasses debounce
- If NAS unreachable: keeps last cached numbers, marks connectionState = .offline
- PHPhotoLibraryChangeObserver triggers refresh on any library change

BackupManifest (new):
- Stored at {remotePath}/.kisani.json on NAS
- Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries
- Built from directory listing if manifest missing (bootstrap for existing backups)
- merged/updated after each backup run via NASTransferProtocol.writeData

BackupStatusSnapshot (new):
- Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal,
  lastCheckedAt, connectionState
- Invariant enforced in service: alreadySafe = min(safe, phoneTotal)

Protocol / services:
- NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String)
- SMBService: implements writeData via temp file + SMBClient.upload
- SFTPService: implements writeData via SFTP ByteBuffer write

BackupEngine:
- Tracks ManifestEntry per successful upload during backup loop
- After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:)
  which applies optimistic UI update then writes manifest + triggers reconcile

BackupView:
- Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount)
- Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe"
- Live refresh triggers: .task (force), scenePhase.active (force),
  nasReachable change (force), remotePath change (force), backup completed (+2s)
- Subtle status row below stats: "Checking…" spinner or "Updated X ago" with
  wifi-slash icon when NAS offline; tap refresh button for forced reconcile
- AppMenuView sheet now correctly passes engine EnvironmentObject

BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess)

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
Robin Kutesa
1312ebb278 Rebuild Activity screen as a live backup activity center
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>
2026-05-17 12:12:26 +03:00
Robin Kutesa
a68cf0e5ec Fix NAS badge color and icon in AppMenuView
- 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>
2026-05-17 12:01:42 +03:00
Robin Kutesa
a077964d75 Fix AppMenuView Connection Status UX and add smart sync dashboard
- 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>
2026-05-17 11:54:43 +03:00
Robin Kutesa
59bdad803f feat: Kisani UI overhaul — brand identity, gallery, visual polish
- Rename app to Kisani (CFBundleDisplayName, xcodeproj → Kisani.xcodeproj)
- BackupView: kisani. wordmark + SwiftUI-drawn server logo mark (no asset,
  adaptive ink/bg), 206pt progress ring with active-state glow, 4-page
  swipeable ring (progress/pending/failed/uploaded), 4-metric stats strip,
  friendly NAS card (Home Server label), hamburger → AppMenuView sheet
- GalleryView: new NAS + local photo grid with source picker chip, 3-col
  lazy grid, ThumbnailCache, sync-status dots (green = backed up)
- MainTabView: Browse tab → Gallery tab; tab bar uses ultraThinMaterial
  blur background (systemGray3 icons, 9.5pt labels)
- All app icon sizes regenerated from 1024pt source via sips
- LANMonitor: nasReachable Bool?, checkNASReachability(host:port:) TCP ping
- project.yml: photo library usage description updated to Kisani branding

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 11:17:13 +03:00
Robin Kutesa
2bdf41502e fix: pin Connect button to bottom, dynamic protocol badge
- 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>
2026-05-16 19:19:37 +03:00
Robin Kutesa
2b6e2d145d refactor: swap disk icon for server.rack on all logo marks
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-16 19:13:05 +03:00
Robin Kutesa
6f7d05a6c2 fix: internaldrive.fill icon, ink/inkInverse adaptive colors
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>
2026-05-16 19:08:43 +03:00
Robin Kutesa
6f7e80023f fix: use SF Symbol in logo mark, bold kisani. wordmark
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>
2026-05-16 19:03:29 +03:00
Robin Kutesa
b46fc20975 fix: render ugreen_logo correctly using template mode on green bg
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>
2026-05-16 18:59:36 +03:00
Robin Kutesa
a40ec86362 refactor: make stats strip much more compact
Reduce vertical padding from 10→6pt, drop large icons, shrink font,
value color now matches stat type (green/red/grey). Strip is ~40% shorter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 18:47:54 +03:00
Robin Kutesa
2e40aa5683 fix(signing): remove Access Wi-Fi entitlement (unsupported on free teams)
- Drop com.apple.developer.networking.wifi-info from project.yml entitlements
  and NSLocationWhenInUseUsageDescription from Info.plist
- SSID detection gracefully returns nil without the capability; all UI handles
  nil SSID already via optional chaining

feat: branding, ring polish, sign-out UX, version/support

- LoginView: replace title with "kisani." monospaced wordmark + UGREEN logo,
  show username then NAS display name after login
- BackupView: "kisani." + UGREEN logo chip in toolbar; ring color is orange
  (in-progress), green (completed), destructive (failed), dim (idle)
- BackupJob: cap progress at min(1.0, ...) to prevent >100% display
- SettingsView sign-out dialog: two actions — "Sign Out" (lock only, keeps NAS)
  and "Sign Out & Forget NAS" (full clear); clearer message
- SettingsView ACCOUNT section: App Version v1.0 + Support mailto rows
- AppMenuView: About section with kisani. wordmark, version, support link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 18:39:35 +03:00
Robin Kutesa
15f9da843c feat: Sync tab, hamburger menu, network status, browse UX
- Replace History tab with Sync tab (file list, NAS status, free-up-space retention)
- Add AppMenuView hamburger sheet (History, Errors, Connection Status, Sync Activity)
- BackupView: Kisani logo chip in toolbar, hamburger menu button, swipeable ring
  pages (progress/pending/failed/uploaded), always-visible compact stats strip
- SettingsView: NETWORK section (SSID, NAS reachability, trusted-network warning,
  refresh button)
- BrowseView: pull-to-refresh, long-press context menu on items
- LANMonitor: nasReachable, nasCheckTime, checkNASReachability(host:port:)
- ConnectionStore: localRetentionDays (0/60/90/365 days)
- Regenerate xcodeproj with xcodegen to include new files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:58:00 +03:00
Robin Kutesa
e1e851360b feat: logo fix, session hierarchy, Switch User, lock vs signOut
Asset fix:
- nas_logo_clean.imageset/Contents.json now registers the 512px PNG at
  all three scales (1x/2x/3x) so iOS uses it on every device type.
  Previously only 1x was wired causing the image to be missing on Retina.
- Logo rendered with thin AppTheme.inkQuaternary ring overlay so its
  dark-background edge stays legible against any app background color.

LoginView overhaul:
- New hierarchy: Kisani title → 90pt logo → NAS displayName → username
- Staggered spring entrance (0.06–0.32s delays, asymmetric ease)
- "Switch User" replaces "More options" — calls store.signOut() to
  clear connection/session/onboarding and return to ConnectView.
- MoreOptionsSheet removed (no longer needed).

ConnectionStore:
- Added lock() — ends session but preserves saved connection (returns to
  LoginView for Face ID re-auth without losing NAS credentials).
- signOut() now clearly documented as clearing everything.
- Removed duplicate sectionLabel() from SettingsView (canonical version
  lives in LANTriggerSection.swift as a top-level free function).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:59:23 +03:00
Robin Kutesa
19d14e58fb fix: SMB share enumeration, BrowseView polish, sign out, Login branding
- Fix "Bad Network Name": BrowseView at root "/" for SMB now calls
  listShares() instead of listDirectory with empty share name.
  SMBClient.listShares() filters IPC/admin/hidden ($) shares.
- Add listShares() to NASTransferProtocol; implement in SMB (filters
  ipc/special/$-suffix) and SFTP (returns []).
- BrowseView: premium error state (wifi.exclamationmark icon + Retry
  button with ScaleButtonStyle), empty state, safeAreaInset bottom bar
  replacing toolbar bottomBar (shows current path chip + compact Select
  button), proper disabled state for root selection on SMB.
- FolderRow: replace hardcoded .white with AppTheme.inkInverse for dark
  mode correctness; use folder.fill icon; ScaleButtonStyle for tap feedback.
- LoginView: larger logo (80pt), bolder "Kisani" wordmark (32pt bold),
  tagline, staggered spring entrance animations (0.08-0.36s delay).
- SettingsView: Sign Out button in ACCOUNT section with
  confirmationDialog (destructive, clears connection + resets session).
- ConnectionStore: signOut() resets isSessionActive, onboardingPhotoShown,
  and clears savedConnection (triggers Keychain delete).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:41:16 +03:00
Robin Kutesa
ae38f6e417 feat: Kisani rebrand, step-based onboarding flow, dark mode fixes
- Rename app to Kisani in all UI text and Face ID prompt
- Replace flat 2-state RootView with 5-step flow: ConnectView →
  LoginView → FolderSetupView → PhotoPermissionView → MainTabView
- Remove NavigationStack from LoginView (was causing nested-stack crash);
  drive navigation via ConnectionStore.isSessionActive
- Add FolderSetupView and PhotoPermissionView onboarding screens with
  step indicator (2/3, 3/3), spring entrance animations, and UGreen
  connection chip
- Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle
  now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25)
- Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light)
- Add ConnectionStore.isSessionActive (non-persisted) and
  onboardingPhotoShown (UserDefaults-persisted)
- Add os_log logging in LoginViewModel at auth start/success/failure
- Remove dead Help button from ConnectView
- UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions,
  PrivacyInfo.xcprivacy, unit test target and test stubs
- AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color
- AppearanceMode enum + segmented picker in SettingsView
- BackupView: enlarged ring, removed options card and linear progress bar
- HistoryView: duration and bytes formatting, improved typography hierarchy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:20:53 +03:00
Robin Kutesa
03d63e60de Add cellular data toggle, Tailscale remote access, fix ToggleRow padding
- ToggleRow: fix cramped layout — proper horizontal + vertical card padding
- ConnectionStore: add allowCellularBackup, useTailscaleWhenRemote, tailscaleHost
- LANMonitor: add isOnCellular (NWPath cellular interface), isTailscaleActive
  (detects utun interface with 100.x.x.x CGNAT address), openTailscaleApp()
- BackupEngine: gate backup on cellular setting; resolveHost() switches to
  tailscaleHost when not on trusted LAN and Tailscale tunnel is active
- SettingsView: CONNECTIVITY section (cellular toggle + live status),
  REMOTE ACCESS section (Tailscale toggle, host field, status dot, Open button)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:40:07 +03:00
Robin Kutesa
07b5e45c78 Revert default protocol to SMB — UGreen NAS uses standard SMB
UGreen UGOS exposes personal folders as named SMB shares (e.g.
'personal_folder'). Third-party apps connect via SMB with the
UGOS username/password — no proprietary protocol involved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:17:17 +03:00
Robin Kutesa
c34dada2b5 Redesign ConnectView for UGreen NAS, default to SFTP
- Standalone IP field with hint labels below (matches UGreen connect pattern)
- Combined credentials card: username + hairline + password with show/hide eye
- Path field shows full absolute path (UGreen uses POSIX paths via SFTP)
- Default protocol changed from SMB → SFTP (UGreen absolute paths are SFTP-native)
- surfaceSunken background, protocol picker stays as secondary control
- Help button stub at bottom

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:11:52 +03:00
Robin Kutesa
29873620b7 Fix presentationCornerRadius iOS 16.0 availability
Wrap in a ViewModifier that guards with #available(iOS 16.4, *),
keeping the rounded sheet on 16.4+ while compiling clean on 16.0+.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:58:26 +03:00
Robin Kutesa
04e7a77c1a Apply Emil Kowalski design across all screens
Near-monochrome palette (ink tokens), black primary buttons, spring
animations, 0.5pt hairline dividers, shadow cards, squircle corners.
Replaces all blue/coloured accents. Adds ButtonStyles component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:43:31 +03:00
Robin Kutesa
b96711c535 Initial scaffold — NASBackup iOS app
Full project scaffold: XcodeGen project.yml, Core models/protocols/errors,
SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2),
PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager,
all feature UIs (Login, Connect, Browse, Backup, History, Settings),
Shared components and theme. Builds clean with zero errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:05:04 +03:00