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>
1338 lines
54 KiB
Swift
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 0→1→0 over `duration` seconds starting at `start`
|
|
private func indicatorChevronPhase(_ cycle: Double, start: Double, duration: Double) -> Double {
|
|
let t = cycle - start
|
|
guard t >= 0, t < duration else { return 0 }
|
|
return sin(t / duration * .pi)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|