Commit Graph

42 Commits

Author SHA1 Message Date
Robin Kutesa
b18a2aad14 fix: remove NAS directory healing (inflated archive count) and record skipped files
The directory healing step added every media file in the NAS folder as a manifest
orphan — including files from other devices — inflating NAS Archive to 7421 and
polluting the manifest with unrelated entries. Removed entirely.

Root cause of the loop: when BackupEngine calls fileExists and skips a file already
on NAS, it never added that file to manifestEntries. The file existed on NAS but
not in the manifest, so every reconcile still showed it as pending, triggering a
new backup on every app open.

Fix: skipped files are now added to manifestEntries with their localIdentifier so
they are written to the manifest at the end of the run (and at checkpoints). Next
reconcile sees them as safe via localIdentifier match and needBackup decrements.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 00:25:45 +03:00
Robin Kutesa
2b2d869ae7 fix: backup loops because isFilenameOnly gate disables filename fallback for mixed manifests
Root cause: ManifestIndex.isFilenameOnly is false as soon as any single entry has a
localIdentifier. All filename-only entries (orphans uploaded in prior sessions without
a saved localIdentifier) are never matched, so their assets appear pending on every
reconcile and the backup re-starts from scratch on every app open.

Fix: add unclaimedFilenames set — filenames of entries with localIdentifier: "".
Use this as a targeted fallback in countSafe / pendingIDs / BackupEngine filter:
an asset not matched by localIdentifier is still considered safe if its filename
matches an unclaimed entry. This is safe because unclaimed entries are specifically
files we know are on NAS but whose identity we can only track by name.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 00:17:16 +03:00
Robin Kutesa
0169486c72 fix: manifest out of sync with NAS causes gallery to show stale item count
Two root causes:
1. Backup interrupted before loop completion = manifest write never fires,
   leaving uploaded files as NAS orphans invisible to gallery and dedup.
2. Manifest only written once at loop end, so any crash loses all progress.

Fixes:
- On each backup run, list the NAS directory and merge any files not tracked
  in the manifest (orphaned from prior interrupted sessions). The healed
  manifest is written back to NAS and NASManifestCache before uploads start,
  so the gallery reflects actual NAS contents immediately on next open.
- Incremental checkpoint writes every 25 uploads using the already-open
  transfer connection — interrupted backups now preserve progress in blocks
  of 25 instead of losing everything.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 00:11:10 +03:00
Robin Kutesa
c7555a2001 fix: manifest cache not updated after backup causes NAS Archive = 1 and backup loop
- writeManifest now updates NASManifestCache after every successful NAS write;
  previously the fast-path reconcile would read the pre-backup cache entry
  (e.g. 1 entry) and overwrite nasArchiveTotal / alreadySafe with stale values
- BackupEngine seeds NASManifestCache when it loads the manifest so the cache
  is always at least as fresh as what the engine sees at backup-start time
- Remove snapshot.nasArchiveTotal += uploaded from refreshAfterBackup; the
  correct total comes from the merged manifest written to NAS, not an additive
  delta from a potentially-wrong baseline
- Fix JSONDecoder() → JSONDecoder.kisani throughout manifest load paths so
  ISO8601 dates decode correctly instead of silently failing and returning nil
- Add retry with exponential backoff (max 3 attempts, 2 s / 4 s intervals)
  to the upload loop; permanently-failed items are now marked .failed only
  after all retries are exhausted

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 00:03:10 +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
0f62268af1 perf: move heavy work off main actor, throttle progress updates, add signposts
BackupEngine:
- fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during
  PHFetchResult enumeration on large libraries
- Manifest JSON decode + ManifestIndex Set construction run in Task.detached
- pendingAssets filter runs in Task.detached — O(N) array filter off main thread
- Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the
  continuous SwiftUI full-tree redraws caused by per-byte upload callbacks
- bytesSoFar captured by value in upload closure — avoids mutable var capture
- reserveCapacity on manifestEntries array
- os_signpost intervals on FetchAssets, NASConnect, ManifestLoad,
  BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling

BackupStatusService:
- countSafe() marked nonisolated — runs in Task.detached off main actor,
  keeping touch/animation responsive during PHFetchResult batch enumeration
- ManifestIndex JSON decode + Set construction run in Task.detached
- Bootstrap ManifestIndex(nasListing:) construction off main actor
- writeManifest JSON decode/encode/merge off main actor
- os_signpost intervals on Reconcile, ManifestLoad, CountSafe

Model types:
- ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable —
  safe to pass across task boundaries without warnings

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 16:42:48 +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
4a7ba9b332 Make backup status reconciliation memory-safe
Replace full [PhotoAsset] array enumeration with PHFetchResult batch
enumeration (autoreleasepool + Task.yield per batch) to keep memory
bounded during reconciliation. Introduce ManifestIndex with two O(1)
Set<String> lookups (localIdentifier + lowercased filename) instead of
O(n×m) linear scans. Scope manifest Data+struct inside buildManifestIndex
so they are released by ARC before the comparison loop begins. Add
UIApplication.didReceiveMemoryWarningNotification handler to cancel
in-flight tasks under memory pressure. Separate debounceTask from
refreshTask to avoid cancelling long-running reconciles on every photo
library event. fetchResultForReconciliation returns PHFetchResult
directly — no [PhotoAsset] array is ever allocated during reconciliation.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 13:06:44 +03:00
Robin Kutesa
2225629dd8 Fix invalid await inside ?? autoclosure in BackupStatusService
Swift does not allow async expressions inside ?? closures.
Replaced with explicit if-let branch so await is at statement level.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:57:05 +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
6cad8d9d6c feat: set kisani app icon (server.rack, black bg, white icon)
Generated at 9 sizes for iPhone home screen and App Store (1024px).
White server.rack SF Symbol on black rounded bg — matches in-app logo.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-16 19:23:53 +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
56b43dca86 Fix 5 build issues: add ButtonStyles to project, actor isolation, buffer mutability
- Re-ran xcodegen so ButtonStyles.swift is included (fixes PrimaryButtonStyle
  and ScaleButtonStyle not in scope in LoginView and LANTriggerSection)
- BackgroundTaskManager: access BackupEngine.shared inside @MainActor Task
  instead of nonisolated context
- SFTPService: change var buffer → let (never mutated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:55:54 +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
848c1da820 Add .gitignore, remove Xcode user data and .DS_Store
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:05:20 +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