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
|
|
|
import SwiftUI
|
|
|
|
|
import Photos
|
|
|
|
|
|
|
|
|
|
struct BackupView: View {
|
|
|
|
|
@EnvironmentObject var engine: BackupEngine
|
|
|
|
|
@EnvironmentObject var store: ConnectionStore
|
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
|
|
|
@EnvironmentObject var lanMonitor: LANMonitor
|
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
|
|
|
@EnvironmentObject var statusService: BackupStatusService
|
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
|
|
|
@EnvironmentObject var coordinator: AutoBackupCoordinator
|
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
|
|
|
@StateObject private var vm = BackupViewModel()
|
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
|
|
|
@Environment(\.scenePhase) private var scenePhase
|
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
|
|
|
|
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
|
|
|
@State private var showMenu = false
|
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
|
|
|
@State private var showDestinationPicker = false
|
|
|
|
|
@State private var pickerPath: String = "/"
|
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
|
|
|
@State private var ringPage = 0
|
|
|
|
|
|
2026-05-19 18:24:55 +03:00
|
|
|
private let ringPageCount = 3
|
|
|
|
|
private let ringPageNames = ["BACKUP", "IPHONE STORAGE", "CLEANUP"]
|
|
|
|
|
|
|
|
|
|
@State private var deviceTotalGB: Double = 0
|
|
|
|
|
@State private var deviceFreeGB: Double = 0
|
|
|
|
|
@State private var showCleanConfirm = false
|
2026-05-20 19:15:42 +03:00
|
|
|
@State private var pullProgress: CGFloat = 0
|
|
|
|
|
@State private var rippleScale: CGFloat = 0.88
|
|
|
|
|
@State private var rippleOpacity: Double = 0
|
|
|
|
|
@State private var pulseDotOpacity: Double = 1.0
|
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
|
|
|
|
2026-05-17 15:58:54 +03:00
|
|
|
// Counts are the source of truth for final display state.
|
|
|
|
|
// If job ended in .failed but reconciliation shows needBackup == 0, treat as .completed.
|
|
|
|
|
private var resolvedStatus: BackupStatus {
|
|
|
|
|
if case .failed = engine.job.status,
|
|
|
|
|
!statusService.isRefreshing,
|
|
|
|
|
statusService.snapshot.phoneTotal > 0,
|
|
|
|
|
statusService.snapshot.needBackup == 0 {
|
|
|
|
|
return .completed
|
|
|
|
|
}
|
|
|
|
|
return engine.job.status
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
var body: some View {
|
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
|
|
|
ZStack(alignment: .topTrailing) {
|
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
|
|
|
AppTheme.background.ignoresSafeArea()
|
|
|
|
|
|
2026-05-20 20:31:43 +03:00
|
|
|
VStack(spacing: 0) {
|
2026-05-19 18:24:55 +03:00
|
|
|
|
2026-05-20 20:31:43 +03:00
|
|
|
// ─── Brand ────────────────────────────────────────────
|
|
|
|
|
VStack(spacing: 10) {
|
|
|
|
|
Text("kisani.")
|
|
|
|
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
|
|
|
.foregroundStyle(AppTheme.ink)
|
|
|
|
|
.kerning(2)
|
|
|
|
|
KisaniLogoMark(size: 66)
|
2026-05-17 15:58:54 +03:00
|
|
|
}
|
2026-05-20 20:31:43 +03:00
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
.padding(.top, 16)
|
|
|
|
|
|
|
|
|
|
Spacer(minLength: 16).frame(maxHeight: 40)
|
|
|
|
|
|
|
|
|
|
// ─── Hero — outside ScrollView so pull gesture fires ───
|
|
|
|
|
heroView
|
|
|
|
|
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: ringPage)
|
|
|
|
|
|
2026-05-21 13:44:24 +03:00
|
|
|
// ─── Page indicator ────────────────────────────────────
|
|
|
|
|
pageIndicator
|
|
|
|
|
.padding(.bottom, 8)
|
|
|
|
|
|
2026-05-20 20:31:43 +03:00
|
|
|
// ─── Scrollable stats / detail ─────────────────────────
|
|
|
|
|
ScrollView(.vertical, showsIndicators: false) {
|
|
|
|
|
belowContent
|
2026-05-21 13:44:24 +03:00
|
|
|
.padding(.bottom, 24)
|
2026-05-17 15:58:54 +03:00
|
|
|
}
|
2026-05-20 20:31:43 +03:00
|
|
|
.refreshable { await triggerRefreshAndBackup() }
|
2026-05-17 15:58:54 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Menu button — floats above scroll content ─────────────
|
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
|
|
|
Button { showMenu = true } label: {
|
|
|
|
|
Image(systemName: "line.3.horizontal")
|
|
|
|
|
.font(.system(size: 14, weight: .regular))
|
|
|
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
|
|
|
.frame(width: 44, height: 44)
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
}
|
|
|
|
|
.accessibilityLabel("Activity menu")
|
|
|
|
|
.padding(.top, 4)
|
|
|
|
|
.padding(.trailing, AppTheme.hPad - 4)
|
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
|
|
|
}
|
2026-05-21 14:54:23 +03:00
|
|
|
.simultaneousGesture(pullGesture)
|
|
|
|
|
.simultaneousGesture(pageSwitchGesture)
|
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
|
|
|
.navigationTitle("")
|
|
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
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
|
|
|
.toolbar(.hidden, for: .navigationBar)
|
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
|
|
|
.sheet(isPresented: $showMenu) {
|
|
|
|
|
AppMenuView()
|
|
|
|
|
.environmentObject(store)
|
|
|
|
|
.environmentObject(lanMonitor)
|
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
|
|
|
.environmentObject(engine)
|
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
|
|
|
}
|
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
|
|
|
.sheet(isPresented: $showDestinationPicker, onDismiss: {
|
|
|
|
|
guard pickerPath != "/", var conn = store.savedConnection else { return }
|
|
|
|
|
conn.remotePath = pickerPath
|
|
|
|
|
store.savedConnection = conn
|
|
|
|
|
}) {
|
|
|
|
|
if let conn = store.savedConnection {
|
|
|
|
|
BrowseView(connection: conn, selectedPath: $pickerPath, isPickerMode: true, initialPath: pickerPath)
|
|
|
|
|
}
|
|
|
|
|
}
|
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
|
|
|
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed)
|
|
|
|
|
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
|
2026-05-19 18:24:55 +03:00
|
|
|
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: ringPage)
|
|
|
|
|
.confirmationDialog(
|
|
|
|
|
"Clean \(String(format: "%.1f", junkGB)) GB from device?",
|
|
|
|
|
isPresented: $showCleanConfirm,
|
|
|
|
|
titleVisibility: .visible
|
|
|
|
|
) {
|
|
|
|
|
Button("Delete Backed-Up Photos", role: .destructive) { /* TODO: trigger cleanup */ }
|
|
|
|
|
Button("Cancel", role: .cancel) {}
|
|
|
|
|
} message: {
|
|
|
|
|
Text("This removes photos already safely backed up to your NAS.")
|
|
|
|
|
}
|
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
|
|
|
.task {
|
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
|
|
|
coordinator.onActive()
|
2026-05-19 18:24:55 +03:00
|
|
|
loadDeviceStorage()
|
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
|
|
|
}
|
|
|
|
|
.onChange(of: scenePhase) { phase in
|
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
|
|
|
// Re-check every time the app returns to the foreground.
|
|
|
|
|
if phase == .active { coordinator.onActive() }
|
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
|
|
|
}
|
|
|
|
|
.onChange(of: store.savedConnection?.remotePath) { _ in
|
|
|
|
|
statusService.refresh(force: true)
|
|
|
|
|
}
|
2026-05-18 23:15:51 +03:00
|
|
|
.onChange(of: store.backupFilter) { _ in
|
|
|
|
|
statusService.refresh(force: true)
|
|
|
|
|
}
|
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
|
|
|
.onChange(of: engine.job.status) { status in
|
|
|
|
|
if status == .completed {
|
|
|
|
|
Task {
|
2026-05-20 19:15:42 +03:00
|
|
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
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
|
|
|
statusService.refresh(force: true)
|
|
|
|
|
}
|
|
|
|
|
}
|
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
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// MARK: — Logo mark (drawn in code — no asset dependency)
|
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
|
|
|
|
|
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
// MARK: — Ring hero (used inside circleSection — no gesture here)
|
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
|
|
|
|
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
|
|
|
private var ringHero: some View {
|
|
|
|
|
ZStack {
|
2026-05-20 19:15:42 +03:00
|
|
|
KisaniRingView(
|
2026-05-21 11:31:01 +03:00
|
|
|
outerProgress: pageOuterProgress,
|
|
|
|
|
outerColor: pageOuterColor,
|
|
|
|
|
innerProgress: pageInnerProgress,
|
|
|
|
|
innerColor: pageInnerColor
|
2026-05-20 19:15:42 +03:00
|
|
|
)
|
|
|
|
|
.accessibilityLabel("Backup progress")
|
2026-05-21 11:31:01 +03:00
|
|
|
.accessibilityValue("\(Int(pageInnerProgress * 100)) percent")
|
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
|
|
|
|
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
|
|
|
VStack(spacing: 4) {
|
2026-05-19 18:24:55 +03:00
|
|
|
ringMainView
|
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
|
|
|
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
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
|
|
|
.id(ringContentID)
|
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
|
|
|
}
|
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
|
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringContentID)
|
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
|
|
|
}
|
2026-05-19 18:24:55 +03:00
|
|
|
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 11:31:01 +03:00
|
|
|
private var ringContentID: String { "\(ringPage)-\(String(describing: widgetState))" }
|
|
|
|
|
|
|
|
|
|
// Arc values routed per page so the ring stays physically fixed
|
|
|
|
|
private var pageOuterProgress: CGFloat {
|
2026-05-21 13:58:50 +03:00
|
|
|
switch ringPage {
|
|
|
|
|
case 1: return usedFraction
|
|
|
|
|
case 2: return CGFloat(min(junkGB / max(1, deviceTotalGB), 1))
|
|
|
|
|
default: return outerRingProgress
|
|
|
|
|
}
|
2026-05-21 11:31:01 +03:00
|
|
|
}
|
|
|
|
|
private var pageOuterColor: Color {
|
2026-05-21 13:58:50 +03:00
|
|
|
switch ringPage {
|
|
|
|
|
case 1: return Color(red: 1.0, green: 0.58, blue: 0.0)
|
|
|
|
|
case 2: return AppTheme.positive
|
|
|
|
|
default: return Color(red: 0.42, green: 0.52, blue: 0.68)
|
|
|
|
|
}
|
2026-05-21 11:31:01 +03:00
|
|
|
}
|
|
|
|
|
private var pageInnerProgress: CGFloat {
|
2026-05-21 13:58:50 +03:00
|
|
|
switch ringPage {
|
|
|
|
|
case 1: return safeFraction
|
|
|
|
|
case 2: return CGFloat(min(backedUpPhotosGB / max(1, deviceTotalGB), 1))
|
|
|
|
|
default: return innerRingProgressWidget
|
|
|
|
|
}
|
2026-05-21 11:31:01 +03:00
|
|
|
}
|
|
|
|
|
private var pageInnerColor: Color {
|
2026-05-21 13:58:50 +03:00
|
|
|
switch ringPage {
|
|
|
|
|
case 1: return AppTheme.positive
|
|
|
|
|
case 2: return AppTheme.positive.opacity(0.55)
|
|
|
|
|
default: return innerRingColor
|
|
|
|
|
}
|
2026-05-21 11:31:01 +03:00
|
|
|
}
|
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
|
|
|
|
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
|
|
|
@ViewBuilder
|
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
|
|
|
private var ringMainView: some View {
|
2026-05-21 13:58:50 +03:00
|
|
|
if ringPage == 2 {
|
|
|
|
|
VStack(spacing: 6) {
|
|
|
|
|
Image(systemName: "leaf")
|
|
|
|
|
.font(.system(size: 14, weight: .ultraLight))
|
|
|
|
|
.foregroundStyle(AppTheme.positive)
|
|
|
|
|
Text(String(format: "%.1f", totalFreeableGB))
|
|
|
|
|
.font(.system(size: 46, weight: .ultraLight))
|
|
|
|
|
.foregroundStyle(AppTheme.positive)
|
|
|
|
|
.kerning(-2)
|
|
|
|
|
Text("GB FREEABLE")
|
|
|
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
|
|
|
.foregroundStyle(AppTheme.positive.opacity(0.75))
|
|
|
|
|
.tracking(2)
|
|
|
|
|
}
|
|
|
|
|
} else if ringPage == 1 {
|
2026-05-21 11:31:01 +03:00
|
|
|
VStack(spacing: 6) {
|
|
|
|
|
Image(systemName: "iphone")
|
|
|
|
|
.font(.system(size: 14, weight: .ultraLight))
|
|
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
|
|
|
Text("\(Int(usedFraction * 100))%")
|
|
|
|
|
.font(.system(size: 46, weight: .ultraLight))
|
|
|
|
|
.foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0))
|
|
|
|
|
.kerning(-2)
|
|
|
|
|
Text("STORAGE USED")
|
|
|
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
|
|
|
.tracking(2).opacity(0.75)
|
|
|
|
|
}
|
|
|
|
|
} else { switch widgetState {
|
2026-05-20 19:15:42 +03:00
|
|
|
case .safe:
|
2026-05-20 19:39:51 +03:00
|
|
|
VStack(spacing: 6) {
|
2026-05-20 19:15:42 +03:00
|
|
|
ringCheckmark
|
2026-05-20 19:39:51 +03:00
|
|
|
Text("\(statusService.snapshot.phoneTotal)")
|
|
|
|
|
.font(.system(size: 46, weight: .ultraLight))
|
|
|
|
|
.foregroundStyle(AppTheme.positive)
|
|
|
|
|
.contentTransition(.numericText())
|
|
|
|
|
.kerning(-2)
|
2026-05-20 19:15:42 +03:00
|
|
|
Text("PHOTOS SAFE")
|
|
|
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
|
|
|
.foregroundStyle(AppTheme.positive.opacity(0.75))
|
|
|
|
|
.tracking(2)
|
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
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
case .checking:
|
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
|
|
|
VStack(spacing: 10) {
|
|
|
|
|
ProgressView()
|
|
|
|
|
.scaleEffect(0.85)
|
|
|
|
|
.tint(AppTheme.inkTertiary)
|
2026-05-20 19:15:42 +03:00
|
|
|
Text("CHECKING")
|
2026-05-19 17:56:23 +03:00
|
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
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
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
2026-05-19 17:56:23 +03:00
|
|
|
.tracking(2).opacity(0.75)
|
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
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
case .running:
|
2026-05-20 19:39:51 +03:00
|
|
|
VStack(spacing: 6) {
|
|
|
|
|
Text("\(notBackedUpDisplayCount)")
|
2026-05-19 17:56:23 +03:00
|
|
|
.font(.system(size: 46, weight: .ultraLight))
|
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
|
|
|
.foregroundStyle(AppTheme.ink)
|
|
|
|
|
.contentTransition(.numericText())
|
2026-05-19 17:56:23 +03:00
|
|
|
.kerning(-2)
|
2026-05-20 19:39:51 +03:00
|
|
|
Text("BACKING UP")
|
2026-05-19 17:56:23 +03:00
|
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
2026-05-20 19:39:51 +03:00
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
|
|
|
.tracking(2).opacity(0.75)
|
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
|
|
|
}
|
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
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
default: // offline, pending, paused
|
|
|
|
|
VStack(spacing: 6) {
|
|
|
|
|
Text("\(statusService.snapshot.needBackup)")
|
|
|
|
|
.font(.system(size: 46, weight: .ultraLight))
|
|
|
|
|
.foregroundStyle(AppTheme.ink)
|
|
|
|
|
.contentTransition(.numericText())
|
|
|
|
|
.kerning(-2)
|
|
|
|
|
Text(statusService.snapshot.phoneTotal == 0 ? "NO PHOTOS" : "NEED BACKUP")
|
2026-05-19 17:56:23 +03:00
|
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
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
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
2026-05-19 17:56:23 +03:00
|
|
|
.tracking(2).opacity(0.75)
|
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
|
|
|
}
|
2026-05-21 11:31:01 +03:00
|
|
|
} } // end else / switch widgetState
|
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
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:56:23 +03:00
|
|
|
private var ringCheckmark: some View {
|
|
|
|
|
ZStack {
|
|
|
|
|
Circle()
|
|
|
|
|
.stroke(AppTheme.positive, lineWidth: 1)
|
|
|
|
|
.frame(width: 22, height: 22)
|
|
|
|
|
Image(systemName: "checkmark")
|
|
|
|
|
.font(.system(size: 8, weight: .medium))
|
|
|
|
|
.foregroundStyle(AppTheme.positive)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var outerRingProgress: CGFloat {
|
|
|
|
|
let nas = CGFloat(nasArchiveDisplayCount)
|
|
|
|
|
let phone = CGFloat(max(1, statusService.snapshot.phoneTotal))
|
|
|
|
|
return min(nas / (nas + phone), 1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
private var innerRingProgressWidget: CGFloat {
|
|
|
|
|
switch widgetState {
|
|
|
|
|
case .safe: return 1.0
|
|
|
|
|
case .running: return CGFloat(engine.job.progress)
|
|
|
|
|
default: return CGFloat(alreadySafeDisplayCount) / CGFloat(max(1, statusService.snapshot.phoneTotal))
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// MARK: — Compact stats strip
|
|
|
|
|
|
2026-05-17 15:30:52 +03:00
|
|
|
// Invariant: alreadySafe + needBackup == phoneTotal always.
|
|
|
|
|
// During active backup show optimistic count (only newly uploaded, not skipped —
|
|
|
|
|
// skipped files are already counted inside snapshot.alreadySafe from the last reconcile).
|
2026-05-18 17:24:56 +03:00
|
|
|
// Live NAS Archive: grows by 1 with each confirmed upload this session.
|
|
|
|
|
// Skipped files (already on NAS) are NOT added — if nasArchiveTotal came from
|
|
|
|
|
// a directory scan they're already counted; if from manifest they weren't tracked
|
|
|
|
|
// but they're present on disk regardless.
|
|
|
|
|
private var nasArchiveDisplayCount: Int {
|
|
|
|
|
let base = statusService.snapshot.nasArchiveTotal
|
|
|
|
|
if engine.job.status.isActive {
|
|
|
|
|
return base + engine.job.uploadedFiles
|
|
|
|
|
}
|
|
|
|
|
return base
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 15:30:52 +03:00
|
|
|
private var alreadySafeDisplayCount: Int {
|
|
|
|
|
let total = statusService.snapshot.phoneTotal
|
|
|
|
|
guard total > 0 else { return 0 }
|
|
|
|
|
let base = statusService.snapshot.alreadySafe
|
|
|
|
|
if engine.job.status.isActive {
|
2026-05-18 17:24:56 +03:00
|
|
|
// Uploaded = newly confirmed on NAS this session.
|
|
|
|
|
// Skipped = fileExists returned true — already on NAS but wasn't in manifest.
|
|
|
|
|
// Both are now confirmed safe; count them both optimistically.
|
|
|
|
|
return min(base + engine.job.uploadedFiles + engine.job.skippedFiles, total)
|
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
|
|
|
}
|
2026-05-18 17:24:56 +03:00
|
|
|
return min(base, total)
|
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
|
|
|
}
|
|
|
|
|
|
2026-05-17 15:30:52 +03:00
|
|
|
private var notBackedUpDisplayCount: Int {
|
|
|
|
|
let total = statusService.snapshot.phoneTotal
|
|
|
|
|
guard total > 0 else { return 0 }
|
|
|
|
|
if engine.job.status.isActive {
|
|
|
|
|
return max(0, total - alreadySafeDisplayCount)
|
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
|
|
|
}
|
2026-05-17 15:30:52 +03:00
|
|
|
return statusService.snapshot.needBackup
|
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
|
|
|
}
|
|
|
|
|
|
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
|
|
|
private var statsStrip: some View {
|
2026-05-19 18:24:55 +03:00
|
|
|
HStack(spacing: 0) {
|
|
|
|
|
compactStat(
|
|
|
|
|
"\(nasArchiveDisplayCount)",
|
2026-05-20 19:39:51 +03:00
|
|
|
label: "NAS",
|
2026-05-19 18:24:55 +03:00
|
|
|
color: AppTheme.interactive
|
|
|
|
|
)
|
2026-05-20 19:55:23 +03:00
|
|
|
statDivider
|
2026-05-19 18:24:55 +03:00
|
|
|
compactStat(
|
|
|
|
|
"\(statusService.snapshot.phoneTotal)",
|
2026-05-20 19:55:23 +03:00
|
|
|
label: "iPhone",
|
2026-05-19 18:24:55 +03:00
|
|
|
color: AppTheme.inkSecondary
|
|
|
|
|
)
|
2026-05-20 19:55:23 +03:00
|
|
|
statDivider
|
2026-05-19 18:24:55 +03:00
|
|
|
compactStat(
|
|
|
|
|
"\(notBackedUpDisplayCount)",
|
2026-05-20 19:55:23 +03:00
|
|
|
label: "Pending",
|
2026-05-20 19:39:51 +03:00
|
|
|
color: Color(red: 1.0, green: 0.62, blue: 0.0)
|
2026-05-19 18:24:55 +03:00
|
|
|
)
|
2026-05-20 19:55:23 +03:00
|
|
|
statDivider
|
2026-05-19 18:24:55 +03:00
|
|
|
compactStat(
|
|
|
|
|
"\(alreadySafeDisplayCount)",
|
2026-05-20 19:55:23 +03:00
|
|
|
label: "Safe",
|
2026-05-19 18:24:55 +03:00
|
|
|
color: AppTheme.positive
|
|
|
|
|
)
|
2026-05-18 21:12:35 +03:00
|
|
|
}
|
2026-05-20 19:55:23 +03:00
|
|
|
.padding(.vertical, 4)
|
2026-05-18 21:12:35 +03:00
|
|
|
}
|
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
|
|
|
|
2026-05-16 18:47:54 +03:00
|
|
|
private func compactStat(_ value: String, label: String, color: Color) -> some View {
|
2026-05-21 14:36:28 +03:00
|
|
|
VStack(spacing: 4) {
|
2026-05-16 00:43:31 +03:00
|
|
|
Text(value)
|
2026-05-21 14:48:46 +03:00
|
|
|
.font(.system(size: 21, weight: .light))
|
2026-05-16 18:47:54 +03:00
|
|
|
.foregroundStyle(color)
|
2026-05-21 14:36:28 +03:00
|
|
|
.monospacedDigit()
|
2026-05-16 00:43:31 +03:00
|
|
|
Text(label)
|
2026-05-21 14:36:28 +03:00
|
|
|
.font(.system(size: 10, weight: .regular))
|
2026-05-16 18:47:54 +03:00
|
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
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
|
|
|
}
|
2026-05-16 00:43:31 +03:00
|
|
|
.frame(maxWidth: .infinity)
|
2026-05-21 14:36:28 +03:00
|
|
|
.padding(.vertical, 8)
|
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
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:55:23 +03:00
|
|
|
private var statDivider: some View {
|
|
|
|
|
Rectangle()
|
2026-05-21 14:30:29 +03:00
|
|
|
.fill(Color(red: 0.88, green: 0.88, blue: 0.88))
|
2026-05-21 14:36:28 +03:00
|
|
|
.frame(width: 1, height: 36)
|
2026-05-20 19:55:23 +03:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
private var thinDivider: some View {
|
2026-05-16 00:43:31 +03:00
|
|
|
Rectangle()
|
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
|
|
|
.fill(AppTheme.separator.opacity(0.5))
|
|
|
|
|
.frame(width: 0.5, height: 18)
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: — Destination row
|
|
|
|
|
|
|
|
|
|
private var destinationRow: some View {
|
|
|
|
|
Button(action: {
|
|
|
|
|
pickerPath = store.savedConnection?.remotePath ?? "/"
|
|
|
|
|
showDestinationPicker = true
|
|
|
|
|
}) {
|
|
|
|
|
HStack(spacing: 10) {
|
|
|
|
|
ZStack {
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(AppTheme.surfaceSunken)
|
|
|
|
|
.frame(width: 34, height: 34)
|
|
|
|
|
Image(systemName: "folder.fill")
|
|
|
|
|
.font(.system(size: 13, weight: .medium))
|
|
|
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let path = store.savedConnection?.remotePath ?? "/"
|
|
|
|
|
let folderName = path == "/" ? "Not set" : (path as NSString).lastPathComponent
|
|
|
|
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
|
|
|
Text(folderName)
|
|
|
|
|
.font(AppTheme.headline())
|
|
|
|
|
.foregroundStyle(path == "/" ? AppTheme.inkTertiary : AppTheme.ink)
|
|
|
|
|
Text("Backup folder")
|
|
|
|
|
.font(AppTheme.micro(10))
|
|
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
|
|
|
|
Image(systemName: "chevron.right")
|
|
|
|
|
.font(.system(size: 11, weight: .medium))
|
|
|
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 14)
|
|
|
|
|
.padding(.vertical, 12)
|
|
|
|
|
.background(AppTheme.surfaceSunken)
|
|
|
|
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(ScaleButtonStyle())
|
|
|
|
|
.disabled(store.savedConnection == nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var nasStatusDotColor: Color {
|
|
|
|
|
switch engine.job.status {
|
|
|
|
|
case .running, .preparing, .paused:
|
|
|
|
|
return Color(red: 1.0, green: 0.58, blue: 0.0)
|
|
|
|
|
default:
|
|
|
|
|
switch lanMonitor.nasReachable {
|
|
|
|
|
case true: return AppTheme.positive
|
|
|
|
|
case false: return AppTheme.destructive
|
|
|
|
|
case nil: return AppTheme.inkQuaternary
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var nasStatusLabel: String {
|
|
|
|
|
switch engine.job.status {
|
|
|
|
|
case .running: return "Backing up"
|
|
|
|
|
case .preparing: return "Preparing"
|
|
|
|
|
case .paused: return "Paused"
|
|
|
|
|
default:
|
|
|
|
|
return lanMonitor.nasReachable == false ? "Offline" : "Ready"
|
|
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// MARK: — NAS status row
|
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
|
|
|
|
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
|
|
|
private var nasStatusRow: some View {
|
2026-05-16 00:43:31 +03:00
|
|
|
HStack(spacing: 10) {
|
|
|
|
|
ZStack {
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(AppTheme.surfaceSunken)
|
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
|
|
|
.frame(width: 34, height: 34)
|
2026-05-16 00:43:31 +03:00
|
|
|
Image(systemName: "externaldrive")
|
|
|
|
|
.font(.system(size: 13, weight: .medium))
|
|
|
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
if let conn = store.savedConnection {
|
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
|
|
|
let friendlyName = conn.displayName == conn.host ? "Home Server" : conn.displayName
|
2026-05-16 00:43:31 +03:00
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
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
|
|
|
Text(friendlyName)
|
2026-05-16 00:43:31 +03:00
|
|
|
.font(AppTheme.headline())
|
|
|
|
|
.foregroundStyle(AppTheme.ink)
|
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
|
|
|
Text("\(conn.nasProtocol.rawValue) · \(conn.host)")
|
|
|
|
|
.font(AppTheme.micro(10))
|
|
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
2026-05-16 00:43:31 +03:00
|
|
|
}
|
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
|
|
|
} else {
|
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
|
|
|
Text("No storage connected")
|
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
|
|
|
.font(AppTheme.body())
|
|
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
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
|
|
|
}
|
2026-05-16 00:43:31 +03:00
|
|
|
|
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
|
|
|
Spacer()
|
2026-05-16 00:43:31 +03:00
|
|
|
|
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
|
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
|
|
|
HStack(spacing: 4) {
|
|
|
|
|
Circle()
|
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
|
|
|
.fill(nasStatusDotColor)
|
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
|
|
|
.frame(width: 6, height: 6)
|
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
|
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusDotColor == AppTheme.positive)
|
|
|
|
|
Text(nasStatusLabel)
|
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
|
|
|
.font(AppTheme.micro())
|
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
|
|
|
.foregroundStyle(nasStatusDotColor)
|
|
|
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel)
|
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
|
|
|
}
|
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
|
|
|
if lanMonitor.nasReachable == false {
|
2026-05-18 16:56:16 +03:00
|
|
|
Text(statusService.refreshError ?? "Unreachable")
|
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
|
|
|
.font(.system(size: 9, weight: .regular))
|
|
|
|
|
.foregroundStyle(AppTheme.destructive.opacity(0.7))
|
2026-05-18 16:56:16 +03:00
|
|
|
.lineLimit(2)
|
|
|
|
|
.multilineTextAlignment(.trailing)
|
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
|
|
|
} else if lanMonitor.nasReachable == true && !engine.job.status.isActive {
|
|
|
|
|
Text("Connected")
|
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
|
|
|
.font(.system(size: 9, weight: .regular))
|
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
|
|
|
.foregroundStyle(AppTheme.positive.opacity(0.7))
|
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
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
}
|
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
|
|
|
.padding(.horizontal, 14)
|
|
|
|
|
.padding(.vertical, 12)
|
2026-05-16 00:43:31 +03:00
|
|
|
.background(AppTheme.surfaceSunken)
|
|
|
|
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
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
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
private func startBackup() {
|
|
|
|
|
guard let conn = store.savedConnection else { return }
|
|
|
|
|
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
|
|
|
|
|
}
|
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
|
|
|
|
2026-05-20 20:31:43 +03:00
|
|
|
private func triggerRefreshAndBackup() async {
|
|
|
|
|
await statusService.refreshAndWait(force: true)
|
|
|
|
|
if statusService.snapshot.needBackup == 0 {
|
|
|
|
|
engine.resolveSuccess()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if engine.job.status == .paused {
|
|
|
|
|
engine.resume()
|
|
|
|
|
} else if !engine.job.status.isActive {
|
|
|
|
|
startBackup()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: — Hero / below dispatch
|
|
|
|
|
|
|
|
|
|
@ViewBuilder
|
|
|
|
|
private var heroView: some View {
|
2026-05-21 13:58:50 +03:00
|
|
|
// All 3 pages share the same circle — arc values switch in-place
|
|
|
|
|
circleSection
|
2026-05-20 20:31:43 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ViewBuilder
|
|
|
|
|
private var belowContent: some View {
|
|
|
|
|
switch ringPage {
|
|
|
|
|
case 1:
|
|
|
|
|
page1BelowContent
|
|
|
|
|
case 2:
|
|
|
|
|
page2BelowContent
|
|
|
|
|
default:
|
|
|
|
|
statsStrip
|
|
|
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
|
|
|
.padding(.top, 4)
|
|
|
|
|
Spacer(minLength: 16).frame(maxHeight: 32)
|
|
|
|
|
nasStatusRow
|
|
|
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
|
|
|
Spacer(minLength: 8).frame(maxHeight: 20)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
private func etaString(_ seconds: TimeInterval) -> String {
|
|
|
|
|
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: — Widget state
|
|
|
|
|
|
|
|
|
|
private enum WidgetState: Equatable {
|
|
|
|
|
case offline, checking, pending, running, safe, paused
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var widgetState: WidgetState {
|
|
|
|
|
if lanMonitor.nasReachable == false { return .offline }
|
|
|
|
|
switch resolvedStatus {
|
|
|
|
|
case .paused: return .paused
|
|
|
|
|
case .running, .preparing: return .running
|
|
|
|
|
case .completed: return .safe
|
|
|
|
|
default: break
|
|
|
|
|
}
|
|
|
|
|
if statusService.isRefreshing || coordinator.phase == .checking { return .checking }
|
|
|
|
|
if statusService.snapshot.needBackup == 0, statusService.snapshot.phoneTotal > 0 { return .safe }
|
|
|
|
|
return .pending
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var innerRingColor: Color {
|
|
|
|
|
switch widgetState {
|
|
|
|
|
case .offline: return AppTheme.destructive
|
|
|
|
|
case .checking: return Color(red: 0.58, green: 0.64, blue: 0.73)
|
|
|
|
|
case .pending: return Color(red: 1.0, green: 0.62, blue: 0.0)
|
|
|
|
|
case .running: return Color(red: 1.0, green: 0.62, blue: 0.0)
|
|
|
|
|
case .safe: return AppTheme.positive
|
|
|
|
|
case .paused: return Color(red: 0.82, green: 0.82, blue: 0.82)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var statusLabelText: String {
|
|
|
|
|
switch widgetState {
|
|
|
|
|
case .offline: return "NAS unreachable"
|
|
|
|
|
case .checking: return "Checking…"
|
|
|
|
|
case .pending: return "Ready to back up"
|
|
|
|
|
case .running: return "Backing up"
|
|
|
|
|
case .safe: return "All safe"
|
|
|
|
|
case .paused: return "Backup paused"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var statusSubText: String {
|
|
|
|
|
switch widgetState {
|
|
|
|
|
case .offline:
|
|
|
|
|
if let date = statusService.snapshot.lastCheckedAt {
|
|
|
|
|
let s = Int(-date.timeIntervalSinceNow)
|
|
|
|
|
return s < 60 ? "last seen \(s) sec ago" : "last seen \(s / 60) min ago"
|
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
|
|
|
}
|
2026-05-20 19:15:42 +03:00
|
|
|
return "unable to connect"
|
|
|
|
|
case .checking: return "reconciling with NAS"
|
|
|
|
|
case .pending: return "pull circle to start"
|
|
|
|
|
case .running:
|
|
|
|
|
if let eta = engine.job.eta { return "\(etaString(eta)) remaining" }
|
|
|
|
|
return "uploading…"
|
|
|
|
|
case .safe: return "synced with NAS"
|
|
|
|
|
case .paused: return "pull circle to resume"
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
private var dotColor: Color {
|
|
|
|
|
switch widgetState {
|
|
|
|
|
case .offline: return AppTheme.destructive
|
|
|
|
|
case .checking: return AppTheme.inkTertiary
|
|
|
|
|
case .pending: return Color(red: 1.0, green: 0.62, blue: 0.0)
|
|
|
|
|
case .running: return Color(red: 1.0, green: 0.62, blue: 0.0)
|
|
|
|
|
case .safe: return AppTheme.positive
|
|
|
|
|
case .paused: return Color(red: 1.0, green: 0.62, blue: 0.0)
|
|
|
|
|
}
|
|
|
|
|
}
|
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
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
private var dotShouldPulse: Bool {
|
|
|
|
|
widgetState == .offline || widgetState == .pending || widgetState == .paused
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var showArrows: Bool {
|
|
|
|
|
widgetState == .offline || widgetState == .pending || widgetState == .paused
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var arrowsColor: Color {
|
|
|
|
|
widgetState == .offline ? AppTheme.destructive : Color(red: 1.0, green: 0.62, blue: 0.0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: — Circle section
|
|
|
|
|
|
|
|
|
|
private var circleSection: some View {
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
ZStack {
|
2026-05-21 11:31:01 +03:00
|
|
|
// Ripple expands outward on backup trigger (page 0 only)
|
2026-05-20 19:15:42 +03:00
|
|
|
Circle()
|
|
|
|
|
.stroke(AppTheme.positive, lineWidth: 1.5)
|
2026-05-20 20:31:43 +03:00
|
|
|
.frame(width: 288, height: 288)
|
2026-05-20 19:15:42 +03:00
|
|
|
.scaleEffect(rippleScale)
|
|
|
|
|
.opacity(rippleOpacity)
|
|
|
|
|
.allowsHitTesting(false)
|
|
|
|
|
|
|
|
|
|
ringHero
|
|
|
|
|
}
|
|
|
|
|
.padding(.bottom, 4)
|
|
|
|
|
|
2026-05-21 11:31:01 +03:00
|
|
|
if ringPage == 0 {
|
|
|
|
|
cascadingArrows
|
|
|
|
|
.frame(height: 16)
|
|
|
|
|
.padding(.bottom, 8)
|
2026-05-20 19:15:42 +03:00
|
|
|
|
2026-05-21 11:31:01 +03:00
|
|
|
circleStatusRow
|
|
|
|
|
.padding(.horizontal, AppTheme.hPad)
|
2026-05-21 13:44:24 +03:00
|
|
|
.padding(.bottom, 10)
|
2026-05-21 11:31:01 +03:00
|
|
|
} else {
|
|
|
|
|
// Match page 0 spacing so the ring stays at the same Y
|
|
|
|
|
Color.clear.frame(height: 60)
|
|
|
|
|
}
|
2026-05-20 19:15:42 +03:00
|
|
|
}
|
2026-05-21 14:12:54 +03:00
|
|
|
// Pull zone floats above the ring on pages 0 and 2
|
2026-05-21 00:07:01 +03:00
|
|
|
.overlay(alignment: .top) {
|
2026-05-21 14:12:54 +03:00
|
|
|
if ringPage == 0 || ringPage == 2 {
|
2026-05-21 11:31:01 +03:00
|
|
|
pullZone
|
|
|
|
|
.offset(y: -max(0, pullProgress * 0.52))
|
|
|
|
|
}
|
2026-05-21 00:07:01 +03:00
|
|
|
}
|
2026-05-20 19:15:42 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var pullZone: some View {
|
|
|
|
|
let pct = min(pullProgress / 58, 1.0)
|
|
|
|
|
let ready = pullProgress >= 58
|
2026-05-21 14:12:54 +03:00
|
|
|
let pullLabel = ringPage == 2 ? "PULL TO CLEAN" : "PULL TO BACK UP"
|
|
|
|
|
let releaseLabel = ringPage == 2 ? "RELEASE TO CLEAN" : "RELEASE TO BACK UP"
|
2026-05-20 19:15:42 +03:00
|
|
|
return VStack(spacing: 3) {
|
|
|
|
|
Text("↓")
|
|
|
|
|
.font(.system(size: 13))
|
|
|
|
|
.foregroundStyle(ready ? AppTheme.positive : AppTheme.inkTertiary)
|
|
|
|
|
.rotationEffect(.degrees(ready ? 180 : pct * 160))
|
|
|
|
|
.opacity(min(pct * 2.5, 1))
|
2026-05-21 14:12:54 +03:00
|
|
|
Text(ready ? releaseLabel : pullLabel)
|
2026-05-20 19:15:42 +03:00
|
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
|
|
|
.foregroundStyle(ready ? AppTheme.positive : AppTheme.inkTertiary)
|
|
|
|
|
.tracking(2)
|
|
|
|
|
.opacity(min(pct * 3, 1))
|
|
|
|
|
}
|
|
|
|
|
.padding(.bottom, 6)
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: max(0, pullProgress * 0.52), alignment: .bottom)
|
|
|
|
|
.clipped()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var cascadingArrows: some View {
|
|
|
|
|
TimelineView(.animation(minimumInterval: 1.0 / 30, paused: !showArrows)) { tl in
|
|
|
|
|
let t = tl.date.timeIntervalSince1970
|
|
|
|
|
HStack(spacing: 3) {
|
|
|
|
|
ForEach(0..<3, id: \.self) { i in
|
|
|
|
|
let phase = ((t.truncatingRemainder(dividingBy: 1.6) - Double(i) * 0.22) + 1.6)
|
|
|
|
|
.truncatingRemainder(dividingBy: 1.6) / 1.6
|
|
|
|
|
Text("↓")
|
|
|
|
|
.font(.system(size: 10))
|
|
|
|
|
.foregroundStyle(arrowsColor)
|
|
|
|
|
.opacity(arrowFlowOpacity(phase))
|
|
|
|
|
.offset(y: arrowFlowOffset(phase))
|
2026-05-18 21:12:35 +03:00
|
|
|
}
|
2026-05-20 19:15:42 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.opacity(showArrows ? 1 : 0)
|
|
|
|
|
.animation(.easeInOut(duration: 0.4), value: showArrows)
|
|
|
|
|
}
|
2026-05-18 21:12:35 +03:00
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
private var circleStatusRow: some View {
|
|
|
|
|
VStack(spacing: 4) {
|
|
|
|
|
HStack(spacing: 6) {
|
|
|
|
|
if widgetState == .checking || widgetState == .running {
|
|
|
|
|
ProgressView()
|
|
|
|
|
.scaleEffect(0.55)
|
|
|
|
|
.tint(AppTheme.inkTertiary)
|
|
|
|
|
.frame(width: 10, height: 10)
|
|
|
|
|
} else {
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(dotColor)
|
|
|
|
|
.frame(width: 6, height: 6)
|
|
|
|
|
.opacity(pulseDotOpacity)
|
|
|
|
|
.onAppear {
|
|
|
|
|
if dotShouldPulse {
|
|
|
|
|
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
|
|
|
|
|
pulseDotOpacity = 0.3
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.onChange(of: dotShouldPulse) { pulse in
|
|
|
|
|
if pulse {
|
|
|
|
|
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
|
|
|
|
|
pulseDotOpacity = 0.3
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
withAnimation { pulseDotOpacity = 1.0 }
|
|
|
|
|
}
|
|
|
|
|
}
|
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
|
|
|
}
|
2026-05-20 19:15:42 +03:00
|
|
|
Text(statusLabelText)
|
|
|
|
|
.font(.system(size: 13, weight: .regular))
|
|
|
|
|
.foregroundStyle(widgetState == .offline ? AppTheme.destructive : AppTheme.ink)
|
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
|
|
|
}
|
2026-05-20 19:15:42 +03:00
|
|
|
Text(statusSubText)
|
|
|
|
|
.font(.system(size: 9, weight: .regular, design: .monospaced))
|
|
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
|
|
|
.tracking(0.08)
|
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
|
|
|
}
|
2026-05-20 19:15:42 +03:00
|
|
|
.multilineTextAlignment(.center)
|
|
|
|
|
.id(widgetState)
|
|
|
|
|
.transition(.opacity.combined(with: .offset(y: 8)))
|
|
|
|
|
.animation(.easeInOut(duration: 0.35), value: widgetState)
|
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
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
// MARK: — Page switch gesture (used on pages 1 & 2 hero areas)
|
|
|
|
|
|
|
|
|
|
private var pageSwitchGesture: some Gesture {
|
|
|
|
|
DragGesture(minimumDistance: 30)
|
|
|
|
|
.onEnded { value in
|
|
|
|
|
let dx = value.translation.width
|
|
|
|
|
guard abs(dx) > abs(value.translation.height) else { return }
|
|
|
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
|
|
|
|
if dx < 0 {
|
|
|
|
|
ringPage = (ringPage + 1) % ringPageCount
|
|
|
|
|
} else {
|
|
|
|
|
ringPage = (ringPage - 1 + ringPageCount) % ringPageCount
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
// MARK: — Pull gesture
|
|
|
|
|
|
|
|
|
|
private var pullGesture: some Gesture {
|
|
|
|
|
DragGesture(minimumDistance: 8)
|
|
|
|
|
.onChanged { value in
|
|
|
|
|
guard value.translation.height > 0 else { return }
|
|
|
|
|
let dy = value.translation.height
|
|
|
|
|
if pullProgress < 58 && dy >= 58 {
|
|
|
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
|
|
|
}
|
|
|
|
|
pullProgress = dy
|
|
|
|
|
}
|
|
|
|
|
.onEnded { value in
|
|
|
|
|
let fired = value.translation.height >= 58
|
|
|
|
|
withAnimation(.spring(response: 0.38, dampingFraction: 0.85)) { pullProgress = 0 }
|
|
|
|
|
if fired {
|
|
|
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
2026-05-21 14:12:54 +03:00
|
|
|
if ringPage == 2 { triggerCleanViaPull() } else { triggerBackupViaPull() }
|
2026-05-20 19:15:42 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func triggerBackupViaPull() {
|
|
|
|
|
rippleScale = 0.88; rippleOpacity = 0.6
|
|
|
|
|
withAnimation(.easeOut(duration: 0.65)) { rippleScale = 1.35; rippleOpacity = 0 }
|
|
|
|
|
if engine.job.status == .paused { engine.resume() } else { startBackup() }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 14:12:54 +03:00
|
|
|
private func triggerCleanViaPull() {
|
|
|
|
|
rippleScale = 0.88; rippleOpacity = 0.6
|
|
|
|
|
withAnimation(.easeOut(duration: 0.65)) { rippleScale = 1.35; rippleOpacity = 0 }
|
|
|
|
|
showCleanConfirm = true
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:15:42 +03:00
|
|
|
private func arrowFlowOpacity(_ phase: Double) -> Double {
|
|
|
|
|
if phase < 0.4 { return phase / 0.4 * 0.7 }
|
|
|
|
|
if phase < 0.8 { return (0.8 - phase) / 0.4 * 0.7 }
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func arrowFlowOffset(_ phase: Double) -> CGFloat {
|
|
|
|
|
if phase < 0.4 { return CGFloat(-3 + phase / 0.4 * 3) }
|
|
|
|
|
if phase < 0.8 { return CGFloat((phase - 0.4) / 0.4 * 3) }
|
|
|
|
|
return 3
|
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
|
|
|
}
|
2026-05-19 18:24:55 +03:00
|
|
|
|
|
|
|
|
// MARK: — Device storage
|
|
|
|
|
|
|
|
|
|
private var deviceUsedGB: Double { max(0, deviceTotalGB - deviceFreeGB) }
|
|
|
|
|
private var junkGB: Double { Double(alreadySafeDisplayCount) * 3.5 / 1000 }
|
|
|
|
|
private var usedFraction: CGFloat { CGFloat(deviceUsedGB / max(1, deviceTotalGB)) }
|
|
|
|
|
private var safeFraction: CGFloat { CGFloat(min(junkGB, deviceTotalGB) / max(1, deviceTotalGB)) }
|
|
|
|
|
private var backedUpPhotosGB: Double { Double(alreadySafeDisplayCount) * 3.5 / 1000 }
|
|
|
|
|
private var totalFreeableGB: Double { backedUpPhotosGB }
|
|
|
|
|
|
|
|
|
|
private func loadDeviceStorage() {
|
|
|
|
|
let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
|
|
|
|
|
let total = (attrs?[.systemSize] as? Int64) ?? 0
|
|
|
|
|
let free = (attrs?[.systemFreeSize] as? Int64) ?? 0
|
|
|
|
|
deviceTotalGB = Double(total) / 1_000_000_000
|
|
|
|
|
deviceFreeGB = Double(free) / 1_000_000_000
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: — Page content
|
|
|
|
|
|
2026-05-20 20:31:43 +03:00
|
|
|
// MARK: — Page below-content
|
2026-05-19 18:24:55 +03:00
|
|
|
|
|
|
|
|
@ViewBuilder
|
2026-05-20 20:31:43 +03:00
|
|
|
private var page1BelowContent: some View {
|
2026-05-21 14:44:38 +03:00
|
|
|
// Storage stats — plain inline, matches stats strip design
|
2026-05-19 18:24:55 +03:00
|
|
|
HStack(spacing: 0) {
|
|
|
|
|
storageStat(String(format: "%.0f GB", deviceUsedGB), label: "USED",
|
|
|
|
|
color: Color(red: 1.0, green: 0.58, blue: 0.0))
|
2026-05-21 14:44:38 +03:00
|
|
|
statDivider
|
2026-05-19 18:24:55 +03:00
|
|
|
storageStat(String(format: "%.0f GB", deviceFreeGB), label: "FREE",
|
|
|
|
|
color: AppTheme.inkSecondary)
|
2026-05-21 14:44:38 +03:00
|
|
|
statDivider
|
2026-05-19 18:24:55 +03:00
|
|
|
storageStat(String(format: "%.1f GB", junkGB), label: "JUNK",
|
|
|
|
|
color: AppTheme.positive)
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, AppTheme.hPad)
|
2026-05-21 13:51:38 +03:00
|
|
|
.padding(.bottom, 18)
|
2026-05-19 18:24:55 +03:00
|
|
|
|
|
|
|
|
// Legend
|
2026-05-21 13:51:38 +03:00
|
|
|
HStack(spacing: 20) {
|
2026-05-19 18:24:55 +03:00
|
|
|
storageLegend(color: Color(red: 1.0, green: 0.58, blue: 0.0), label: "used")
|
|
|
|
|
storageLegend(color: AppTheme.positive, label: "safe to delete")
|
|
|
|
|
storageLegend(color: AppTheme.destructive, label: "junk")
|
|
|
|
|
}
|
2026-05-21 13:51:38 +03:00
|
|
|
.frame(maxWidth: .infinity)
|
2026-05-19 18:24:55 +03:00
|
|
|
.padding(.horizontal, AppTheme.hPad)
|
2026-05-21 13:51:38 +03:00
|
|
|
.padding(.bottom, 36)
|
2026-05-19 18:24:55 +03:00
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ViewBuilder
|
2026-05-20 20:31:43 +03:00
|
|
|
private var page2BelowContent: some View {
|
2026-05-19 18:24:55 +03:00
|
|
|
// Breakdown
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
cleanupRow(label: "Backed-up photos", sizeGB: backedUpPhotosGB, color: AppTheme.positive)
|
|
|
|
|
Divider().opacity(0.25).padding(.leading, AppTheme.hPad)
|
|
|
|
|
cleanupRow(label: "Duplicates", sizeGB: 0, color: AppTheme.interactive)
|
|
|
|
|
Divider().opacity(0.25).padding(.leading, AppTheme.hPad)
|
|
|
|
|
cleanupRow(label: "Screenshots", sizeGB: 0,
|
|
|
|
|
color: Color(red: 1.0, green: 0.58, blue: 0.0))
|
|
|
|
|
Divider().opacity(0.25).padding(.leading, AppTheme.hPad)
|
|
|
|
|
cleanupRow(label: "Large videos", sizeGB: 0, color: AppTheme.inkQuaternary)
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
|
|
|
.padding(.bottom, 24)
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 13:44:24 +03:00
|
|
|
// MARK: — Page indicator
|
2026-05-19 18:24:55 +03:00
|
|
|
|
2026-05-21 13:44:24 +03:00
|
|
|
private var pageIndicator: some View {
|
|
|
|
|
VStack(spacing: 9) {
|
|
|
|
|
// Segmented bar — one pill per page, active = amber
|
|
|
|
|
HStack(spacing: 5) {
|
|
|
|
|
ForEach(0..<ringPageCount, id: \.self) { i in
|
|
|
|
|
Capsule()
|
|
|
|
|
.fill(i == ringPage
|
|
|
|
|
? Color(red: 1.0, green: 0.58, blue: 0.0)
|
|
|
|
|
: Color(red: 0.78, green: 0.78, blue: 0.78).opacity(0.4))
|
|
|
|
|
.frame(width: 26, height: 2)
|
|
|
|
|
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: ringPage)
|
2026-05-19 18:24:55 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 14:01:16 +03:00
|
|
|
// Square advance button — animated to hint pull-down
|
2026-05-19 18:24:55 +03:00
|
|
|
Button {
|
|
|
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
|
|
|
|
ringPage = (ringPage + 1) % ringPageCount
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
2026-05-21 14:01:16 +03:00
|
|
|
TimelineView(.animation(minimumInterval: 1.0 / 30)) { tl in
|
|
|
|
|
let t = tl.date.timeIntervalSince1970
|
|
|
|
|
// 2.4s cycle: two cascading pulses then a long pause
|
|
|
|
|
let cycle = t.truncatingRemainder(dividingBy: 2.4)
|
|
|
|
|
let p1 = indicatorChevronPhase(cycle, start: 0.00, duration: 0.38)
|
|
|
|
|
let p2 = indicatorChevronPhase(cycle, start: 0.42, duration: 0.38)
|
|
|
|
|
|
|
|
|
|
ZStack {
|
|
|
|
|
// Primary chevron
|
|
|
|
|
Image(systemName: "chevron.down")
|
|
|
|
|
.font(.system(size: 9, weight: .medium))
|
|
|
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
|
|
|
.offset(y: CGFloat(p1) * 4)
|
|
|
|
|
.opacity(1.0 - p1 * 0.35)
|
|
|
|
|
// Ghost — trails below with delay
|
|
|
|
|
Image(systemName: "chevron.down")
|
|
|
|
|
.font(.system(size: 8, weight: .medium))
|
|
|
|
|
.foregroundStyle(AppTheme.inkSecondary.opacity(0.45))
|
|
|
|
|
.offset(y: CGFloat(p2) * 4 + 7)
|
|
|
|
|
.opacity(p2 * 0.85)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(width: 30, height: 30)
|
|
|
|
|
.overlay(
|
|
|
|
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
|
|
|
|
.stroke(Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.55), lineWidth: 1)
|
|
|
|
|
)
|
2026-05-19 18:24:55 +03:00
|
|
|
}
|
2026-05-21 13:44:24 +03:00
|
|
|
.buttonStyle(.plain)
|
2026-05-19 18:24:55 +03:00
|
|
|
}
|
2026-05-21 13:44:24 +03:00
|
|
|
.frame(maxWidth: .infinity)
|
2026-05-19 18:24:55 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 14:01:16 +03:00
|
|
|
// sin-bell phase: rises from 0→1→0 over `duration` seconds starting at `start`
|
|
|
|
|
private func indicatorChevronPhase(_ cycle: Double, start: Double, duration: Double) -> Double {
|
|
|
|
|
let t = cycle - start
|
|
|
|
|
guard t >= 0, t < duration else { return 0 }
|
|
|
|
|
return sin(t / duration * .pi)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:24:55 +03:00
|
|
|
// MARK: — Storage helpers
|
|
|
|
|
|
|
|
|
|
private func storageStat(_ value: String, label: String, color: Color) -> some View {
|
2026-05-21 14:44:38 +03:00
|
|
|
VStack(spacing: 4) {
|
2026-05-19 18:24:55 +03:00
|
|
|
Text(value)
|
2026-05-21 14:48:46 +03:00
|
|
|
.font(.system(size: 21, weight: .light))
|
2026-05-19 18:24:55 +03:00
|
|
|
.foregroundStyle(color)
|
2026-05-21 14:41:00 +03:00
|
|
|
.monospacedDigit()
|
2026-05-19 18:24:55 +03:00
|
|
|
Text(label)
|
2026-05-21 14:44:38 +03:00
|
|
|
.font(.system(size: 10, weight: .regular))
|
2026-05-19 18:24:55 +03:00
|
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity)
|
2026-05-21 14:44:38 +03:00
|
|
|
.padding(.vertical, 8)
|
2026-05-19 18:24:55 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func storageLegend(color: Color, label: String) -> some View {
|
|
|
|
|
HStack(spacing: 5) {
|
|
|
|
|
RoundedRectangle(cornerRadius: 1)
|
|
|
|
|
.fill(color)
|
|
|
|
|
.frame(width: 14, height: 2)
|
|
|
|
|
Text(label)
|
|
|
|
|
.font(.system(size: 9, weight: .regular))
|
|
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func cleanupRow(label: String, sizeGB: Double, color: Color) -> some View {
|
|
|
|
|
HStack(spacing: 12) {
|
|
|
|
|
Text(label)
|
|
|
|
|
.font(AppTheme.body(13))
|
|
|
|
|
.foregroundStyle(AppTheme.ink)
|
|
|
|
|
Spacer()
|
|
|
|
|
GeometryReader { geo in
|
|
|
|
|
ZStack(alignment: .leading) {
|
|
|
|
|
Rectangle().fill(AppTheme.surfaceSunken).frame(height: 2)
|
|
|
|
|
Rectangle()
|
|
|
|
|
.fill(color)
|
|
|
|
|
.frame(
|
|
|
|
|
width: max(4, geo.size.width * CGFloat(
|
|
|
|
|
totalFreeableGB > 0 ? min(sizeGB / totalFreeableGB, 1) : 0
|
|
|
|
|
)),
|
|
|
|
|
height: 2
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxHeight: .infinity, alignment: .center)
|
|
|
|
|
}
|
|
|
|
|
.frame(height: 2)
|
|
|
|
|
.frame(maxWidth: 140)
|
|
|
|
|
Text(sizeGB > 0.01 ? String(format: "%.1f GB", sizeGB) : "—")
|
|
|
|
|
.font(.system(size: 10, weight: .regular, design: .monospaced))
|
|
|
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
|
|
|
.frame(width: 50, alignment: .trailing)
|
|
|
|
|
}
|
|
|
|
|
.padding(.vertical, 12)
|
|
|
|
|
}
|
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
|
|
|
}
|
2026-05-19 17:56:23 +03:00
|
|
|
|
|
|
|
|
// MARK: — Ring
|
|
|
|
|
|
|
|
|
|
private struct KisaniRingView: View {
|
|
|
|
|
let outerProgress: CGFloat
|
2026-05-21 11:31:01 +03:00
|
|
|
let outerColor: Color
|
2026-05-19 17:56:23 +03:00
|
|
|
let innerProgress: CGFloat
|
2026-05-20 19:15:42 +03:00
|
|
|
let innerColor: Color
|
2026-05-19 17:56:23 +03:00
|
|
|
|
2026-05-21 11:31:01 +03:00
|
|
|
private let track = Color(red: 0.922, green: 0.922, blue: 0.922) // #ebebeb
|
2026-05-19 17:56:23 +03:00
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
ZStack {
|
|
|
|
|
// Outer track
|
|
|
|
|
Circle().stroke(track, lineWidth: 1).padding(10)
|
2026-05-21 11:31:01 +03:00
|
|
|
// Outer arc — color is caller-supplied (NAS slate or storage orange)
|
2026-05-19 17:56:23 +03:00
|
|
|
Circle().trim(from: 0, to: outerProgress)
|
2026-05-21 11:31:01 +03:00
|
|
|
.stroke(outerColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
|
|
|
|
.shadow(color: outerColor.opacity(0.55), radius: 5, x: 0, y: 0)
|
2026-05-19 17:56:23 +03:00
|
|
|
.rotationEffect(.degrees(-90))
|
|
|
|
|
.padding(10)
|
|
|
|
|
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: outerProgress)
|
|
|
|
|
|
2026-05-20 19:55:23 +03:00
|
|
|
// Middle static ring — #c8c8c8
|
|
|
|
|
Circle().stroke(Color(red: 0.784, green: 0.784, blue: 0.784), lineWidth: 1).padding(24)
|
2026-05-19 17:56:23 +03:00
|
|
|
|
|
|
|
|
// Inner track
|
|
|
|
|
Circle().stroke(track, lineWidth: 1.5).padding(38)
|
2026-05-20 19:15:42 +03:00
|
|
|
// Inner progress (color-variable)
|
2026-05-19 17:56:23 +03:00
|
|
|
Circle().trim(from: 0, to: innerProgress)
|
2026-05-20 19:15:42 +03:00
|
|
|
.stroke(innerColor, style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
|
2026-05-19 17:56:23 +03:00
|
|
|
.rotationEffect(.degrees(-90))
|
|
|
|
|
.padding(38)
|
|
|
|
|
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress)
|
2026-05-20 19:15:42 +03:00
|
|
|
.animation(.easeInOut(duration: 0.5), value: innerColor)
|
2026-05-19 17:56:23 +03:00
|
|
|
}
|
2026-05-20 20:31:43 +03:00
|
|
|
.frame(width: 270, height: 270)
|
2026-05-19 17:56:23 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-19 18:24:55 +03:00
|
|
|
|
|
|
|
|
private struct StorageRingView: View {
|
|
|
|
|
let usedProgress: CGFloat
|
|
|
|
|
let safeProgress: CGFloat
|
|
|
|
|
|
|
|
|
|
private let track = Color(red: 0.92, green: 0.92, blue: 0.92)
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
ZStack {
|
|
|
|
|
// Outer track + used (orange)
|
|
|
|
|
Circle().stroke(track, lineWidth: 1).padding(10)
|
|
|
|
|
Circle().trim(from: 0, to: usedProgress)
|
|
|
|
|
.stroke(
|
|
|
|
|
Color(red: 1.0, green: 0.58, blue: 0.0),
|
|
|
|
|
style: StrokeStyle(lineWidth: 1, lineCap: .round)
|
|
|
|
|
)
|
|
|
|
|
.rotationEffect(.degrees(-90))
|
|
|
|
|
.padding(10)
|
|
|
|
|
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: usedProgress)
|
|
|
|
|
|
|
|
|
|
// Middle static ring (gray)
|
|
|
|
|
Circle().stroke(Color(red: 0.78, green: 0.78, blue: 0.78), lineWidth: 1).padding(24)
|
|
|
|
|
|
|
|
|
|
// Inner track + safe-to-delete (green)
|
|
|
|
|
Circle().stroke(track, lineWidth: 1.5).padding(38)
|
|
|
|
|
Circle().trim(from: 0, to: safeProgress)
|
|
|
|
|
.stroke(
|
|
|
|
|
AppTheme.positive,
|
|
|
|
|
style: StrokeStyle(lineWidth: 1.5, lineCap: .round)
|
|
|
|
|
)
|
|
|
|
|
.rotationEffect(.degrees(-90))
|
|
|
|
|
.padding(38)
|
|
|
|
|
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: safeProgress)
|
|
|
|
|
}
|
2026-05-20 20:31:43 +03:00
|
|
|
.frame(width: 270, height: 270)
|
2026-05-19 18:24:55 +03:00
|
|
|
}
|
|
|
|
|
}
|