// // CleanupView.swift // MUA // // Created by Mitchel Volkering on 21/12/2025. // import SwiftUI /// Categories of cleanable items enum CleanupCategory: String, CaseIterable, Identifiable { case caches = "Caches" case logs = "Logs" case trash = "Trash" case downloads = "Old Downloads" var id: String { rawValue } var icon: String { switch self { case .caches: return "memorychip" case .logs: return "doc.text" case .trash: return "trash" case .downloads: return "arrow.down.circle" } } var color: Color { switch self { case .caches: return .purple case .logs: return .orange case .trash: return .red case .downloads: return .blue } } var description: String { switch self { case .caches: return "Application caches that can be safely removed" case .logs: return "System and app log files" case .trash: return "Items in your Trash" case .downloads: return "Files older than 30 days in Downloads" } } var paths: [URL] { let home = FileManager.default.homeDirectoryForCurrentUser switch self { case .caches: return [ home.appending(path: "Library/Caches"), ] case .logs: return [ home.appending(path: "Library/Logs"), ] case .trash: return [ home.appending(path: ".Trash"), ] case .downloads: return [ home.appending(path: "Downloads"), ] } } } struct CleanupItem: Identifiable { let id = UUID() let category: CleanupCategory var size: Int64 var items: [FileItem] var isSelected: Bool = true } /// Cleanup view for removing unnecessary files struct CleanupView: View { @State private var cleanupItems: [CleanupItem] = [] @State private var isScanning = false @State private var isCleaning = false @State private var showConfirmation = false private var totalSize: Int64 { cleanupItems.filter(\.isSelected).reduce(0) { $0 + $1.size } } var body: some View { VStack(spacing: 0) { // Header headerView // Categories if isScanning { scanningView } else if cleanupItems.isEmpty { emptyView } else { categoriesList } } .background(Color(nsColor: .windowBackgroundColor)) .alert("Clean Selected Items?", isPresented: $showConfirmation) { Button("Cancel", role: .cancel) {} Button("Clean", role: .destructive) { Task { await performCleanup() } } } message: { Text("This will permanently delete \(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)) of data. This cannot be undone.") } } private var headerView: some View { HStack(spacing: 20) { // Total savings indicator VStack(alignment: .leading, spacing: 4) { Text("Potential Savings") .font(.subheadline) .foregroundStyle(.secondary) Text(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)) .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(.primary) } Spacer() // Actions Button { Task { await scanForCleanup() } } label: { Label("Scan", systemImage: "arrow.clockwise") } .buttonStyle(.bordered) .disabled(isScanning || isCleaning) Button { showConfirmation = true } label: { Label("Clean", systemImage: "trash") } .buttonStyle(.borderedProminent) .tint(.red) .disabled(totalSize == 0 || isScanning || isCleaning) } .padding() .background(.ultraThinMaterial) } private var scanningView: some View { VStack(spacing: 20) { ProgressView() .scaleEffect(1.5) Text("Scanning for cleanable items...") .font(.headline) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private var emptyView: some View { VStack(spacing: 20) { ZStack { Circle() .fill( .linearGradient( colors: [.green.opacity(0.6), .green.opacity(0.3)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 100, height: 100) .overlay( Circle() .strokeBorder(.white.opacity(0.3), lineWidth: 2) ) .shadow(color: .green.opacity(0.3), radius: 15) Image(systemName: "checkmark") .font(.system(size: 40, weight: .semibold)) .foregroundStyle(.white) } Text("Ready to Clean") .font(.title2) .fontWeight(.medium) Text("Scan your system to find cleanable items") .font(.subheadline) .foregroundStyle(.secondary) Button { Task { await scanForCleanup() } } label: { Label("Start Scan", systemImage: "magnifyingglass") } .buttonStyle(.borderedProminent) .controlSize(.large) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private var categoriesList: some View { ScrollView { LazyVStack(spacing: 12) { ForEach($cleanupItems) { $item in CleanupCategoryCard(item: $item) } } .padding() } } private func scanForCleanup() async { isScanning = true cleanupItems = [] // Get all category paths on main thread first let categoryPaths: [(CleanupCategory, [URL])] = CleanupCategory.allCases.map { ($0, $0.paths) } // Scan all categories on background thread let results = await Task.detached(priority: .userInitiated) { var categoryResults: [CleanupItem] = [] for (category, paths) in categoryPaths { var size: Int64 = 0 var files: [FileItem] = [] for path in paths { let (pathSize, pathFiles) = CleanupView.scanPathSync(path, category: category) size += pathSize files.append(contentsOf: pathFiles) } if size > 0 { categoryResults.append(CleanupItem( category: category, size: size, items: files )) } } return categoryResults }.value cleanupItems = results isScanning = false } private nonisolated static func scanPathSync(_ url: URL, category: CleanupCategory) -> (Int64, [FileItem]) { let fileManager = FileManager.default var totalSize: Int64 = 0 var items: [FileItem] = [] guard let enumerator = fileManager.enumerator( at: url, includingPropertiesForKeys: [.fileSizeKey, .creationDateKey], options: [.skipsHiddenFiles] ) else { return (0, []) } let thirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60) while let fileURL = enumerator.nextObject() as? URL { do { let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey, .creationDateKey, .isDirectoryKey]) // For downloads, only include old files if category == .downloads { if let creationDate = resourceValues.creationDate, creationDate > thirtyDaysAgo { continue } } if resourceValues.isDirectory == false { let size = Int64(resourceValues.fileSize ?? 0) totalSize += size if size > 1_000_000 { // Only track files > 1MB items.append(FileItem(url: fileURL, isDirectory: false, size: size)) } } } catch { continue } } return (totalSize, items.sorted { $0.size > $1.size }.prefix(20).map { $0 }) } private func performCleanup() async { isCleaning = true for item in cleanupItems where item.isSelected { for file in item.items { do { try FileManager.default.trashItem(at: file.url, resultingItemURL: nil) } catch { print("Failed to clean \(file.url): \(error)") } } } // Rescan after cleanup await scanForCleanup() isCleaning = false } } struct CleanupCategoryCard: View { @Binding var item: CleanupItem @State private var isExpanded = false @State private var isHovered = false var body: some View { VStack(spacing: 0) { // Header HStack(spacing: 16) { // Toggle Toggle(isOn: $item.isSelected) { EmptyView() } .toggleStyle(.checkbox) // Icon ZStack { RoundedRectangle(cornerRadius: 10) .fill( .linearGradient( colors: [ item.category.color.opacity(0.6), item.category.color.opacity(0.3) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 44, height: 44) .overlay( RoundedRectangle(cornerRadius: 10) .strokeBorder(.white.opacity(0.2), lineWidth: 1) ) Image(systemName: item.category.icon) .font(.system(size: 20)) .foregroundStyle(.white) } VStack(alignment: .leading, spacing: 2) { Text(item.category.rawValue) .font(.headline) Text(item.category.description) .font(.caption) .foregroundStyle(.secondary) } Spacer() Text(ByteCountFormatter.string(fromByteCount: item.size, countStyle: .file)) .font(.system(.title3, design: .rounded, weight: .semibold)) .foregroundStyle(item.isSelected ? .primary : .secondary) Button { withAnimation(.spring(response: 0.3)) { isExpanded.toggle() } } label: { Image(systemName: "chevron.right") .rotationEffect(.degrees(isExpanded ? 90 : 0)) .foregroundStyle(.secondary) } .buttonStyle(.plain) } .padding() // Expanded items if isExpanded && !item.items.isEmpty { Divider() .padding(.horizontal) VStack(spacing: 0) { ForEach(item.items.prefix(10)) { file in HStack { Image(systemName: file.icon) .foregroundStyle(file.color) .frame(width: 20) Text(file.name) .lineLimit(1) .truncationMode(.middle) Spacer() Text(file.formattedSize) .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal) .padding(.vertical, 6) } if item.items.count > 10 { Text("And \(item.items.count - 10) more files...") .font(.caption) .foregroundStyle(.tertiary) .padding(.vertical, 8) } } .padding(.bottom) } } .background( RoundedRectangle(cornerRadius: 14) .fill(.regularMaterial) .overlay( RoundedRectangle(cornerRadius: 14) .strokeBorder( item.isSelected ? item.category.color.opacity(0.3) : Color.clear, lineWidth: 1 ) ) ) .shadow(color: .black.opacity(0.05), radius: 5, y: 2) .scaleEffect(isHovered ? 1.005 : 1.0) .animation(.spring(response: 0.3), value: isHovered) .onHover { hovering in isHovered = hovering } } } #Preview { CleanupView() .frame(width: 700, height: 600) }