300 lines
11 KiB
Swift
300 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
struct CategoryDetailView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
let category: CleaningCategory
|
|
|
|
@State private var sortDescending: Bool = true
|
|
@State private var searchText = ""
|
|
@State private var showConfirmation = false
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
private var result: CategoryResult? {
|
|
appState.categoryResults[category]
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
heroCard
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 12)
|
|
|
|
Group {
|
|
if let result = result {
|
|
if result.items.isEmpty {
|
|
EmptyStateView("All Clean", systemImage: "checkmark.circle", description: "No junk files found in this category.", tint: Tint.green)
|
|
} else {
|
|
fileList(result)
|
|
}
|
|
} else {
|
|
EmptyStateView("Not Scanned", systemImage: category.icon, description: "Run a scan to analyze this category.", action: { appState.scanSingleCategory(category) }, actionLabel: "Scan Now", tint: category.color)
|
|
}
|
|
}
|
|
}
|
|
.searchable(text: $searchText, prompt: "Filter files")
|
|
.navigationTitle(Text(LocalizedStringKey(category.rawValue)))
|
|
.toolbar {
|
|
ToolbarItem(placement: .automatic) {
|
|
Button {
|
|
appState.scanSingleCategory(category)
|
|
} label: {
|
|
Label("Scan", systemImage: "arrow.clockwise")
|
|
}
|
|
.disabled(appState.scanState.isActive)
|
|
}
|
|
|
|
ToolbarItemGroup(placement: .automatic) {
|
|
if let result = result, !result.items.isEmpty {
|
|
Button("Select All") {
|
|
appState.selectAllInCategory(category)
|
|
}
|
|
Button("Deselect All") {
|
|
appState.deselectAllInCategory(category)
|
|
}
|
|
Button(action: { sortDescending.toggle() }) {
|
|
Label {
|
|
Text(LocalizedStringKey(sortDescending ? "Largest First" : "Smallest First"))
|
|
} icon: {
|
|
Image(systemName: "arrow.up.arrow.down")
|
|
}
|
|
}
|
|
.help(LocalizedStringKey(sortDescending ? "Sorted: Largest First" : "Sorted: Smallest First"))
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .automatic) {
|
|
if let _ = result, !appState.scanState.isActive {
|
|
let selectedSize = appState.selectedSizeInCategory(category)
|
|
let selectedCount = appState.selectedCountInCategory(category)
|
|
if selectedSize > 0 {
|
|
Button {
|
|
showConfirmation = true
|
|
} label: {
|
|
Label {
|
|
Text(cleanItemsLabel(count: selectedCount))
|
|
} icon: {
|
|
Image(systemName: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.confirmationDialog(cleanConfirmationTitle, isPresented: $showConfirmation, titleVisibility: .visible) {
|
|
Button("Clean", role: .destructive) {
|
|
appState.cleanCategory(category)
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This will permanently delete the selected files. This cannot be undone.")
|
|
}
|
|
}
|
|
|
|
private func cleanItemsLabel(count: Int) -> String {
|
|
String(format: String(localized: "Clean %lld items"), Int64(count))
|
|
}
|
|
|
|
private var cleanConfirmationTitle: String {
|
|
String(
|
|
format: String(localized: "Clean %@?"),
|
|
ByteCountFormatter.string(fromByteCount: appState.selectedSizeInCategory(category), countStyle: .file)
|
|
)
|
|
}
|
|
|
|
// MARK: - Hero
|
|
|
|
private var heroCard: some View {
|
|
let totalSize = result?.totalSize ?? 0
|
|
let itemCount = result?.itemCount ?? 0
|
|
let isScanning = appState.scanState.isActive
|
|
|
|
return CardSurface(padding: 18) {
|
|
HStack(alignment: .center, spacing: 16) {
|
|
IconTile(systemName: category.icon, tint: category.color, size: 56, corner: 14)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(LocalizedStringKey(category.rawValue))
|
|
.font(.system(size: 22, weight: .semibold))
|
|
Text(LocalizedStringKey(category.description))
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
if itemCount > 0 {
|
|
Text(itemsAndSizeText(itemCount: itemCount, totalSize: totalSize))
|
|
.font(.system(size: 11.5, weight: .medium))
|
|
.monospacedDigit()
|
|
.contentTransition(reduceMotion ? .identity : .numericText())
|
|
.foregroundStyle(category.color)
|
|
.padding(.top, 2)
|
|
.animation(reduceMotion ? nil : MotionTokens.gentle, value: totalSize)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
appState.scanSingleCategory(category)
|
|
} label: {
|
|
Label {
|
|
Text(scanButtonLabel(isScanning: isScanning, hasResult: result != nil))
|
|
} icon: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.font(.system(size: 12.5, weight: .semibold))
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.large)
|
|
.disabled(isScanning)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func itemsAndSizeText(itemCount: Int, totalSize: Int64) -> String {
|
|
String(
|
|
format: String(localized: "%lld items · %@"),
|
|
Int64(itemCount),
|
|
ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
|
|
)
|
|
}
|
|
|
|
private func scanButtonLabel(isScanning: Bool, hasResult: Bool) -> LocalizedStringKey {
|
|
if isScanning { return "Scanning…" }
|
|
if hasResult { return "Rescan" }
|
|
return "Scan"
|
|
}
|
|
|
|
// MARK: - File List
|
|
|
|
private func fileList(_ result: CategoryResult) -> some View {
|
|
let items = sortedItems(result.items).filter { item in
|
|
searchText.isEmpty || item.name.localizedCaseInsensitiveContains(searchText) || item.path.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
return List {
|
|
Section {
|
|
// No .staggered() here: List is lazy, so a delayed-reveal
|
|
// modifier would blank each row for ~0.45s as it scrolls into
|
|
// view on large scans. The row hover/selection polish carries
|
|
// the motion; the list-level sort/filter animation handles
|
|
// reorders.
|
|
ForEach(Array(items.enumerated()), id: \.element.id) { _, item in
|
|
FileRowView(item: item)
|
|
}
|
|
} header: {
|
|
let selectedCount = appState.selectedCountInCategory(category)
|
|
let totalCount = result.itemCount
|
|
Text(
|
|
String(
|
|
format: String(localized: "%lld of %lld selected"),
|
|
Int64(selectedCount),
|
|
Int64(totalCount)
|
|
)
|
|
)
|
|
.monospacedDigit()
|
|
.contentTransition(reduceMotion ? .identity : .numericText())
|
|
.animation(reduceMotion ? nil : MotionTokens.gentle, value: selectedCount)
|
|
}
|
|
}
|
|
// CleanableItem ids are stable, so SwiftUI move-animates re-sorts and
|
|
// fades filtered rows instead of snapping.
|
|
.animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.85), value: sortDescending)
|
|
.animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: searchText)
|
|
}
|
|
|
|
private func sortedItems(_ items: [CleanableItem]) -> [CleanableItem] {
|
|
items.sorted { sortDescending ? $0.size > $1.size : $0.size < $1.size }
|
|
}
|
|
}
|
|
|
|
// MARK: - File Row View
|
|
|
|
private struct FileRowView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
let item: CleanableItem
|
|
|
|
@State private var hovering = false
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
private var isSelected: Bool {
|
|
appState.isItemSelected(item)
|
|
}
|
|
|
|
var body: some View {
|
|
Toggle(isOn: Binding(
|
|
get: { isSelected },
|
|
set: { _ in appState.toggleItem(item) }
|
|
)) {
|
|
HStack {
|
|
Image(systemName: fileIcon)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 20)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(item.name)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
|
|
if !item.path.isEmpty {
|
|
Text(item.path)
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if let date = item.lastModified {
|
|
Text(date, style: .date)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Text(item.formattedSize)
|
|
.font(.callout)
|
|
.fontWeight(.medium)
|
|
.monospacedDigit()
|
|
.frame(width: 80, alignment: .trailing)
|
|
}
|
|
}
|
|
.toggleStyle(AnimatedCheckboxStyle(tint: item.category.color))
|
|
.padding(.vertical, 2)
|
|
.padding(.horizontal, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.fill(
|
|
hovering
|
|
? Color.primary.opacity(0.06)
|
|
: (isSelected ? item.category.color.opacity(0.04) : .clear)
|
|
)
|
|
)
|
|
.animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering)
|
|
.animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: isSelected)
|
|
.onHover { hovering = $0 }
|
|
.contextMenu {
|
|
if !item.path.isEmpty {
|
|
Button("Reveal in Finder") {
|
|
NSWorkspace.shared.selectFile(item.path, inFileViewerRootedAtPath: "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var fileIcon: String {
|
|
let ext = (item.name as NSString).pathExtension.lowercased()
|
|
switch ext {
|
|
case "log", "txt": return "doc.text"
|
|
case "zip", "gz", "tar": return "doc.zipper"
|
|
case "dmg", "iso": return "opticaldisc"
|
|
case "app": return "app"
|
|
case "pkg": return "shippingbox"
|
|
default:
|
|
var isDir: ObjCBool = false
|
|
if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir), isDir.boolValue {
|
|
return "folder"
|
|
}
|
|
return "doc"
|
|
}
|
|
}
|
|
}
|