Files
wehub-resource-sync 869b84f27c
Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:29:30 +08:00

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"
}
}
}