Files
Kisani/Features/Backup/BackupView.swift
Robin Kutesa 5a55931233 Replace clean confirm dialog with multi-option sheet
Pull-to-clean on pages 1 and 2 now opens a half-sheet with:
- Backed-up photos row (always selected, shows ~GB)
- Screenshots section with 7 / 21 / 30 / 60 day toggle buttons
- CLEAN NOW button proceeds to final confirmation

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 15:06:43 +03:00

1248 lines
49 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 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)
Spacer(minLength: 16).frame(maxHeight: 40)
// 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
ScrollView(.vertical, showsIndicators: false) {
belowContent
.padding(.bottom, 24)
}
.refreshable { await triggerRefreshAndBackup() }
}
// 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()
}
.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: Color(red: 1.0, green: 0.62, blue: 0.0)
)
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
}
@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)
}
}
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)
if ringPage == 0 {
cascadingArrows
.frame(height: 16)
.padding(.bottom, 8)
circleStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 10)
} else {
// Match page 0 spacing so the ring stays at the same Y
Color.clear.frame(height: 60)
}
}
// 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 = ringPage == 0 ? "PULL TO BACK UP" : "PULL TO CLEAN"
let releaseLabel = ringPage == 0 ? "RELEASE TO BACK UP" : "RELEASE TO CLEAN"
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()
if ringPage == 0 { triggerBackupViaPull() } else { triggerCleanViaPull() }
}
}
}
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 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 }
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(.bottom, 18)
// 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, 36)
}
@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: "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)
}
// 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 (always included, non-toggleable)
HStack(spacing: 12) {
ZStack {
Circle().fill(AppTheme.positive.opacity(0.12)).frame(width: 36, height: 36)
Image(systemName: "photo.on.rectangle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppTheme.positive)
}
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: "checkmark.circle.fill")
.font(.system(size: 18))
.foregroundStyle(AppTheme.positive)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 14)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.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)
}
}