Files
Kisani/Features/Backup/BackupView.swift
Robin Kutesa 1a40d4c1b6 Extend swipe and pull gestures to full screen
Moved pullGesture and pageSwitchGesture from the ring ZStack to
the outermost ZStack via simultaneousGesture. Both gestures now
fire from anywhere on the screen without blocking scroll.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-21 14:54:23 +03:00

1114 lines
43 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 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.")
}
.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 pages 0 and 2
.overlay(alignment: .top) {
if ringPage == 0 || ringPage == 2 {
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 == 2 ? "PULL TO CLEAN" : "PULL TO BACK UP"
let releaseLabel = ringPage == 2 ? "RELEASE TO CLEAN" : "RELEASE TO BACK UP"
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 == 2 { triggerCleanViaPull() } else { triggerBackupViaPull() }
}
}
}
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 }
showCleanConfirm = 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: 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)
}
}