Files
Kisani/Features/Backup/BackupView.swift
Robin Kutesa 920888efc1 fix(backup): Pending stat goes gray when 0, orange only when items need backup
Orange on "0 Pending" implied an active warning state when everything was
already backed up. Now dims to inkQuaternary at 0, returns to orange when
there are actually pending items.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-26 12:33:06 +03:00

1338 lines
54 KiB
Swift

import SwiftUI
import Photos
struct BackupView: View {
@EnvironmentObject var engine: BackupEngine
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@EnvironmentObject var statusService: BackupStatusService
@EnvironmentObject var coordinator: AutoBackupCoordinator
@StateObject private var vm = BackupViewModel()
@Environment(\.scenePhase) private var scenePhase
@State private var showMenu = false
@State private var showDestinationPicker = false
@State private var pickerPath: String = "/"
@State private var ringPage = 0
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
@State private var showCleanSheet = false
@State private var cleanScreenshotDays: Int? = nil
@State private var cleanDeletedPhotos: Bool = false
@State private var cleanBackedUpPhotos: Bool = true
@State private var deletedPhotosGB: Double = 0
@State private var screenshotsGB: Double = 0
@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
// 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
}
var body: some View {
ZStack(alignment: .topTrailing) {
AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) {
// Brand
VStack(spacing: 10) {
Text("kisani.")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(AppTheme.ink)
.kerning(2)
KisaniLogoMark(size: 66)
}
.frame(maxWidth: .infinity)
.padding(.top, 16)
Color.clear.frame(height: 24)
// Hero outside ScrollView so pull gesture fires
heroView
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: ringPage)
// Page indicator
pageIndicator
.padding(.bottom, 8)
// Scrollable stats / detail
if ringPage == 0 {
ScrollView(.vertical, showsIndicators: false) {
statsStrip
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 4)
Color.clear.frame(height: 20)
nasStatusRow
.padding(.horizontal, AppTheme.hPad)
}
.refreshable { await triggerRefreshAndBackup() }
} else if ringPage == 1 {
page1BelowContent
} else {
ScrollView(.vertical, showsIndicators: false) {
page2BelowContent
}
}
}
// Menu button floats above scroll content
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)
}
.simultaneousGesture(pullGesture)
.simultaneousGesture(pageSwitchGesture)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $showMenu) {
AppMenuView()
.environmentObject(store)
.environmentObject(lanMonitor)
.environmentObject(engine)
}
.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)
}
}
.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)
.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.")
}
.sheet(isPresented: $showCleanSheet) { cleanOptionsSheet }
.task {
coordinator.onActive()
loadDeviceStorage()
loadCleanupData()
}
.onChange(of: scenePhase) { phase in
// Re-check every time the app returns to the foreground.
if phase == .active { coordinator.onActive() }
}
.onChange(of: store.savedConnection?.remotePath) { _ in
statusService.refresh(force: true)
}
.onChange(of: store.backupFilter) { _ in
statusService.refresh(force: true)
}
.onChange(of: engine.job.status) { status in
if status == .completed {
Task {
try? await Task.sleep(nanoseconds: 1_000_000_000)
statusService.refresh(force: true)
}
}
}
}
// MARK: Logo mark (drawn in code no asset dependency)
// MARK: Ring hero (used inside circleSection no gesture here)
private var ringHero: some View {
ZStack {
KisaniRingView(
outerProgress: pageOuterProgress,
outerColor: pageOuterColor,
innerProgress: pageInnerProgress,
innerColor: pageInnerColor
)
.accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(pageInnerProgress * 100)) percent")
VStack(spacing: 4) {
ringMainView
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.id(ringContentID)
}
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringContentID)
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
private var ringContentID: String { "\(ringPage)-\(String(describing: widgetState))" }
// Arc values routed per page so the ring stays physically fixed
private var pageOuterProgress: CGFloat {
switch ringPage {
case 1: return usedFraction
case 2: return CGFloat(min(junkGB / max(1, deviceTotalGB), 1))
default: return outerRingProgress
}
}
private var pageOuterColor: Color {
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)
}
}
private var pageInnerProgress: CGFloat {
switch ringPage {
case 1: return safeFraction
case 2: return CGFloat(min(backedUpPhotosGB / max(1, deviceTotalGB), 1))
default: return innerRingProgressWidget
}
}
private var pageInnerColor: Color {
switch ringPage {
case 1: return AppTheme.positive
case 2: return AppTheme.positive.opacity(0.55)
default: return innerRingColor
}
}
@ViewBuilder
private var ringMainView: some View {
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 {
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 {
case .safe:
VStack(spacing: 6) {
ringCheckmark
Text("\(statusService.snapshot.phoneTotal)")
.font(.system(size: 46, weight: .ultraLight))
.foregroundStyle(AppTheme.positive)
.contentTransition(.numericText())
.kerning(-2)
Text("PHOTOS SAFE")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.positive.opacity(0.75))
.tracking(2)
}
case .checking:
VStack(spacing: 10) {
ProgressView()
.scaleEffect(0.85)
.tint(AppTheme.inkTertiary)
Text("CHECKING")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
}
case .running:
VStack(spacing: 6) {
Text("\(notBackedUpDisplayCount)")
.font(.system(size: 46, weight: .ultraLight))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.kerning(-2)
Text("BACKING UP")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
}
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")
.font(.system(size: 8, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.tracking(2).opacity(0.75)
}
} } // end else / switch widgetState
}
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)
}
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))
}
}
// MARK: Compact stats strip
// 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).
// 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
}
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 {
// 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)
}
return min(base, total)
}
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)
}
return statusService.snapshot.needBackup
}
private var statsStrip: some View {
HStack(spacing: 0) {
compactStat(
"\(nasArchiveDisplayCount)",
label: "NAS",
color: AppTheme.interactive
)
statDivider
compactStat(
"\(statusService.snapshot.phoneTotal)",
label: "iPhone",
color: AppTheme.inkSecondary
)
statDivider
compactStat(
"\(notBackedUpDisplayCount)",
label: "Pending",
color: notBackedUpDisplayCount > 0
? Color(red: 1.0, green: 0.62, blue: 0.0)
: AppTheme.inkQuaternary
)
statDivider
compactStat(
"\(alreadySafeDisplayCount)",
label: "Safe",
color: AppTheme.positive
)
}
.padding(.vertical, 4)
}
private func compactStat(_ value: String, label: String, color: Color) -> some View {
VStack(spacing: 4) {
Text(value)
.font(.system(size: 21, weight: .light))
.foregroundStyle(color)
.monospacedDigit()
Text(label)
.font(.system(size: 10, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
private var statDivider: some View {
Rectangle()
.fill(Color(red: 0.88, green: 0.88, blue: 0.88))
.frame(width: 1, height: 36)
}
private var thinDivider: some View {
Rectangle()
.fill(AppTheme.separator.opacity(0.5))
.frame(width: 0.5, height: 18)
}
// 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"
}
}
// MARK: NAS status row
private var nasStatusRow: some View {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 34, height: 34)
Image(systemName: "externaldrive")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
if let conn = store.savedConnection {
let friendlyName = conn.displayName == conn.host ? "Home Server" : conn.displayName
VStack(alignment: .leading, spacing: 1) {
Text(friendlyName)
.font(AppTheme.headline())
.foregroundStyle(AppTheme.ink)
Text("\(conn.nasProtocol.rawValue) · \(conn.host)")
.font(AppTheme.micro(10))
.foregroundStyle(AppTheme.inkTertiary)
}
} else {
Text("No storage connected")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
HStack(spacing: 4) {
Circle()
.fill(nasStatusDotColor)
.frame(width: 6, height: 6)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusDotColor == AppTheme.positive)
Text(nasStatusLabel)
.font(AppTheme.micro())
.foregroundStyle(nasStatusDotColor)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel)
}
if lanMonitor.nasReachable == false {
Text(statusService.refreshError ?? "Unreachable")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.destructive.opacity(0.7))
.lineLimit(2)
.multilineTextAlignment(.trailing)
} else if lanMonitor.nasReachable == true && !engine.job.status.isActive {
Text("Connected")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.positive.opacity(0.7))
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
}
private func startBackup() {
guard let conn = store.savedConnection else { return }
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
}
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 {
// All 3 pages share the same circle arc values switch in-place
circleSection
}
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"
}
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"
}
}
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)
}
}
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 {
// Ripple expands outward on backup trigger (page 0 only)
Circle()
.stroke(AppTheme.positive, lineWidth: 1.5)
.frame(width: 288, height: 288)
.scaleEffect(rippleScale)
.opacity(rippleOpacity)
.allowsHitTesting(false)
ringHero
}
.padding(.bottom, 4)
// Fixed-height container guarantees identical Y for all pages
ZStack(alignment: .top) {
if ringPage == 0 {
VStack(spacing: 0) {
cascadingArrows
.frame(height: 16)
.padding(.bottom, 8)
circleStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 10)
}
}
}
.frame(height: 66)
}
// Pull zone floats above the ring on all pages
.overlay(alignment: .top) {
pullZone
.offset(y: -max(0, pullProgress * 0.52))
}
}
private var pullZone: some View {
let pct = min(pullProgress / 58, 1.0)
let ready = pullProgress >= 58
let pullLabel: String
let releaseLabel: String
switch ringPage {
case 0: pullLabel = "PULL TO BACK UP"; releaseLabel = "RELEASE TO BACK UP"
case 1: pullLabel = "PULL TO CLEAN"; releaseLabel = "RELEASE TO CLEAN"
default: pullLabel = "PULL TO REFRESH"; releaseLabel = "RELEASE TO REFRESH"
}
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))
Text(ready ? releaseLabel : pullLabel)
.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))
}
}
}
.opacity(showArrows ? 1 : 0)
.animation(.easeInOut(duration: 0.4), value: showArrows)
}
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 }
}
}
}
Text(statusLabelText)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(widgetState == .offline ? AppTheme.destructive : AppTheme.ink)
}
Text(statusSubText)
.font(.system(size: 9, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.tracking(0.08)
}
.multilineTextAlignment(.center)
.id(widgetState)
.transition(.opacity.combined(with: .offset(y: 8)))
.animation(.easeInOut(duration: 0.35), value: widgetState)
}
// 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
}
}
}
}
// 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()
switch ringPage {
case 0: triggerBackupViaPull()
case 1: triggerCleanViaPull()
default: triggerRefreshViaPull()
}
}
}
}
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() }
}
private func triggerRefreshViaPull() {
rippleScale = 0.88; rippleOpacity = 0.4
withAnimation(.easeOut(duration: 0.65)) { rippleScale = 1.35; rippleOpacity = 0 }
loadDeviceStorage()
loadCleanupData()
statusService.refresh(force: true)
}
private func triggerCleanViaPull() {
rippleScale = 0.88; rippleOpacity = 0.6
withAnimation(.easeOut(duration: 0.65)) { rippleScale = 1.35; rippleOpacity = 0 }
showCleanSheet = true
}
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
}
// 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 + deletedPhotosGB + screenshotsGB }
private func loadCleanupData() {
// Recently Deleted fetch by title since the subtype isn't public
let delOpts = PHFetchOptions()
delOpts.predicate = NSPredicate(format: "localizedTitle = 'Recently Deleted'")
let delCollections = PHAssetCollection.fetchAssetCollections(
with: .smartAlbum, subtype: .any, options: delOpts)
if let col = delCollections.firstObject {
let assets = PHAsset.fetchAssets(in: col, options: nil)
deletedPhotosGB = Double(assets.count) * 3.5 / 1000
}
// Screenshots
let ssCollections = PHAssetCollection.fetchAssetCollections(
with: .smartAlbum, subtype: .smartAlbumScreenshots, options: nil)
if let col = ssCollections.firstObject {
let assets = PHAsset.fetchAssets(in: col, options: nil)
screenshotsGB = Double(assets.count) * 1.5 / 1000
}
}
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
// MARK: Page below-content
@ViewBuilder
private var page1BelowContent: some View {
// Storage stats plain inline, matches stats strip design
HStack(spacing: 0) {
storageStat(String(format: "%.0f GB", deviceUsedGB), label: "USED",
color: Color(red: 1.0, green: 0.58, blue: 0.0))
statDivider
storageStat(String(format: "%.0f GB", deviceFreeGB), label: "FREE",
color: AppTheme.inkSecondary)
statDivider
storageStat(String(format: "%.1f GB", junkGB), label: "JUNK",
color: AppTheme.positive)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 8)
.padding(.bottom, 14)
// Legend
HStack(spacing: 20) {
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")
}
.frame(maxWidth: .infinity)
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 24)
}
@ViewBuilder
private var page2BelowContent: some View {
// Breakdown
VStack(spacing: 0) {
cleanupRow(label: "Backed-up photos", sizeGB: backedUpPhotosGB, color: AppTheme.positive)
Divider().opacity(0.25).padding(.leading, AppTheme.hPad)
cleanupRow(label: "Recently Deleted", sizeGB: deletedPhotosGB, color: AppTheme.destructive)
Divider().opacity(0.25).padding(.leading, AppTheme.hPad)
cleanupRow(label: "Screenshots", sizeGB: screenshotsGB,
color: Color(red: 1.0, green: 0.58, blue: 0.0))
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: "Large videos", sizeGB: 0, color: AppTheme.inkQuaternary)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 24)
}
// MARK: Page indicator
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)
}
}
// Square advance button animated to hint pull-down
Button {
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
ringPage = (ringPage + 1) % ringPageCount
}
} label: {
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)
)
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity)
}
// sin-bell phase: rises from 010 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)
}
// MARK: Clean options sheet
private var cleanOptionsSheet: some View {
VStack(spacing: 0) {
// Handle
Capsule()
.fill(AppTheme.inkQuaternary.opacity(0.4))
.frame(width: 36, height: 4)
.padding(.top, 12)
.padding(.bottom, 20)
// Header
HStack {
Text("Clean Storage")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(AppTheme.ink)
Spacer()
Button { showCleanSheet = false } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 22))
.foregroundStyle(AppTheme.inkQuaternary)
}
}
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 24)
// Row: backed-up photos (toggleable)
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
cleanBackedUpPhotos.toggle()
}
} label: {
HStack(spacing: 12) {
ZStack {
Circle()
.fill((cleanBackedUpPhotos ? AppTheme.positive : AppTheme.inkQuaternary).opacity(0.12))
.frame(width: 36, height: 36)
Image(systemName: "photo.on.rectangle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(cleanBackedUpPhotos ? AppTheme.positive : AppTheme.inkTertiary)
}
VStack(alignment: .leading, spacing: 2) {
Text("Backed-up photos")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppTheme.ink)
Text(String(format: "~%.1f GB · already safe on NAS", junkGB))
.font(.system(size: 11))
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
Image(systemName: cleanBackedUpPhotos ? "checkmark.circle.fill" : "circle")
.font(.system(size: 18))
.foregroundStyle(cleanBackedUpPhotos ? AppTheme.positive : AppTheme.inkQuaternary)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 14)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
// Recently Deleted toggle
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
cleanDeletedPhotos.toggle()
}
} label: {
HStack(spacing: 12) {
ZStack {
Circle()
.fill((cleanDeletedPhotos ? AppTheme.destructive : AppTheme.inkQuaternary).opacity(0.12))
.frame(width: 36, height: 36)
Image(systemName: "trash")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(cleanDeletedPhotos ? AppTheme.destructive : AppTheme.inkTertiary)
}
VStack(alignment: .leading, spacing: 2) {
Text("Recently Deleted")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppTheme.ink)
Text(deletedPhotosGB > 0.01
? String(format: "~%.1f GB · iPhone trash", deletedPhotosGB)
: "Empty · iPhone trash")
.font(.system(size: 11))
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
Image(systemName: cleanDeletedPhotos ? "checkmark.circle.fill" : "circle")
.font(.system(size: 18))
.foregroundStyle(cleanDeletedPhotos ? AppTheme.destructive : AppTheme.inkQuaternary)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 14)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
// Screenshots section
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
ZStack {
Circle().fill(AppTheme.interactive.opacity(0.12)).frame(width: 36, height: 36)
Image(systemName: "camera.viewfinder")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppTheme.interactive)
}
VStack(alignment: .leading, spacing: 2) {
Text("Screenshots")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppTheme.ink)
Text(cleanScreenshotDays == nil ? "Not selected" : "Older than \(cleanScreenshotDays!) days")
.font(.system(size: 11))
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
}
// Day picker
HStack(spacing: 8) {
ForEach([7, 21, 30, 60], id: \.self) { days in
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
cleanScreenshotDays = cleanScreenshotDays == days ? nil : days
}
} label: {
Text("\(days) days")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(cleanScreenshotDays == days ? .white : AppTheme.inkSecondary)
.frame(maxWidth: .infinity)
.frame(height: 34)
.background(cleanScreenshotDays == days
? AppTheme.interactive
: AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 14)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 28)
Spacer(minLength: 0)
// Action button
Button {
showCleanSheet = false
showCleanConfirm = true
} label: {
Text("CLEAN NOW")
.font(.system(size: 13, weight: .medium, design: .monospaced))
.tracking(2)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(Color(red: 0.08, green: 0.08, blue: 0.08))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, AppTheme.hPad)
Text("This cannot be undone")
.font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundStyle(AppTheme.inkQuaternary)
.tracking(1)
.padding(.top, 10)
.padding(.bottom, 28)
}
.presentationDetents([.medium])
.presentationDragIndicator(.hidden)
}
// MARK: Storage helpers
private func storageStat(_ value: String, label: String, color: Color) -> some View {
VStack(spacing: 4) {
Text(value)
.font(.system(size: 21, weight: .light))
.foregroundStyle(color)
.monospacedDigit()
Text(label)
.font(.system(size: 10, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
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)
}
}
// MARK: Ring
private struct KisaniRingView: View {
let outerProgress: CGFloat
let outerColor: Color
let innerProgress: CGFloat
let innerColor: Color
private let track = Color(red: 0.922, green: 0.922, blue: 0.922) // #ebebeb
var body: some View {
ZStack {
// Outer track
Circle().stroke(track, lineWidth: 1).padding(10)
// Outer arc color is caller-supplied (NAS slate or storage orange)
Circle().trim(from: 0, to: outerProgress)
.stroke(outerColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
.shadow(color: outerColor.opacity(0.55), radius: 5, x: 0, y: 0)
.rotationEffect(.degrees(-90))
.padding(10)
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: outerProgress)
// Middle static ring #c8c8c8
Circle().stroke(Color(red: 0.784, green: 0.784, blue: 0.784), lineWidth: 1).padding(24)
// Inner track
Circle().stroke(track, lineWidth: 1.5).padding(38)
// Inner progress (color-variable)
Circle().trim(from: 0, to: innerProgress)
.stroke(innerColor, style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
.rotationEffect(.degrees(-90))
.padding(38)
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress)
.animation(.easeInOut(duration: 0.5), value: innerColor)
}
.frame(width: 270, height: 270)
}
}
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)
}
.frame(width: 270, height: 270)
}
}