diff --git a/PureMac/Models/Models.swift b/PureMac/Models/Models.swift index 78a9572..926124e 100644 --- a/PureMac/Models/Models.swift +++ b/PureMac/Models/Models.swift @@ -1,5 +1,100 @@ import SwiftUI +// MARK: - Filter & Sort + +struct FilterConfig { + var searchText: String = "" + var sizeFilter: SizeFilter = .all + var dateFilter: DateFilter = .all + var sortBy: SortBy = .size + var sortOrder: SortOrder = .descending + + func matches(_ item: CleanableItem) -> Bool { + // Search text + if !searchText.isEmpty { + if !item.name.localizedCaseInsensitiveContains(searchText) && + !item.path.localizedCaseInsensitiveContains(searchText) { + return false + } + } + + // Size filter + if !sizeFilter.matches(item.size) { + return false + } + + // Date filter + if let date = item.lastModified { + if !dateFilter.matches(date) { + return false + } + } else if dateFilter != .all { + return false + } + + return true + } +} + +enum SizeFilter: String, CaseIterable, Identifiable { + case all = "All Sizes" + case small = "Small (< 10 MB)" + case medium = "Medium (10 - 100 MB)" + case large = "Large (> 100 MB)" + + var id: String { rawValue } + + func matches(_ size: Int64) -> Bool { + switch self { + case .all: return true + case .small: return size < 10 * 1024 * 1024 + case .medium: return size >= 10 * 1024 * 1024 && size <= 100 * 1024 * 1024 + case .large: return size > 100 * 1024 * 1024 + } + } +} + +enum DateFilter: String, CaseIterable, Identifiable { + case all = "All Time" + case today = "Today" + case week = "Last 7 Days" + case month = "Last 30 Days" + case year = "Last Year" + + var id: String { rawValue } + + func matches(_ date: Date) -> Bool { + let now = Date() + let calendar = Calendar.current + switch self { + case .all: return true + case .today: return calendar.isDateInToday(date) + case .week: + let weekAgo = calendar.date(byAdding: .day, value: -7, to: now)! + return date >= weekAgo + case .month: + let monthAgo = calendar.date(byAdding: .month, value: -1, to: now)! + return date >= monthAgo + case .year: + let yearAgo = calendar.date(byAdding: .year, value: -1, to: now)! + return date >= yearAgo + } + } +} + +enum SortBy: String, CaseIterable, Identifiable { + case size = "Size" + case date = "Date" + case name = "Name" + + var id: String { rawValue } +} + +enum SortOrder { + case ascending + case descending +} + // MARK: - Cleaning Category enum CleaningCategory: String, CaseIterable, Identifiable, Codable { diff --git a/PureMac/Services/ScanEngine.swift b/PureMac/Services/ScanEngine.swift index c657460..77b6d30 100644 --- a/PureMac/Services/ScanEngine.swift +++ b/PureMac/Services/ScanEngine.swift @@ -314,7 +314,7 @@ actor ScanEngine { size: size, category: .xcodeJunk, isSelected: true, - lastModified: nil + lastModified: fileModDate(path: path) )) } } @@ -346,7 +346,7 @@ actor ScanEngine { size: size, category: .brewCache, isSelected: true, - lastModified: nil + lastModified: fileModDate(path: path) )) } } diff --git a/PureMac/ViewModels/AppViewModel.swift b/PureMac/ViewModels/AppViewModel.swift index 8f32f25..64e055b 100644 --- a/PureMac/ViewModels/AppViewModel.swift +++ b/PureMac/ViewModels/AppViewModel.swift @@ -65,6 +65,18 @@ class AppViewModel: ObservableObject { } } + func selectItems(_ items: [CleanableItem]) { + for item in items { + deselectedItems.remove(item.id) + } + } + + func deselectItems(_ items: [CleanableItem]) { + for item in items { + deselectedItems.insert(item.id) + } + } + func selectedSizeInCategory(_ category: CleaningCategory) -> Int64 { guard let result = categoryResults[category] else { return 0 } return result.items.filter { isItemSelected($0) }.reduce(0) { $0 + $1.size } diff --git a/PureMac/Views/CategoryDetailView.swift b/PureMac/Views/CategoryDetailView.swift index 92bf7dc..b8ba87a 100644 --- a/PureMac/Views/CategoryDetailView.swift +++ b/PureMac/Views/CategoryDetailView.swift @@ -5,12 +5,32 @@ struct CategoryDetailView: View { @EnvironmentObject var vm: AppViewModel let category: CleaningCategory - @State private var sortDescending: Bool = true + @State private var filter = FilterConfig() var result: CategoryResult? { vm.categoryResults[category] } + var filteredItems: [CleanableItem] { + guard let items = result?.items else { return [] } + + let filtered = items.filter { filter.matches($0) } + + return filtered.sorted { a, b in + let ascending = filter.sortOrder == .ascending + switch filter.sortBy { + case .size: + return ascending ? a.size < b.size : a.size > b.size + case .date: + let d1 = a.lastModified ?? Date.distantPast + let d2 = b.lastModified ?? Date.distantPast + return ascending ? d1 < d2 : d1 > d2 + case .name: + return ascending ? a.name < b.name : a.name > b.name + } + } + } + var body: some View { VStack(spacing: 0) { // Category header @@ -23,6 +43,11 @@ struct CategoryDetailView: View { if result.items.isEmpty { emptyState } else { + // Search & Filter Bar + filterBar + .padding(.horizontal, 32) + .padding(.bottom, 8) + // File list fileList(result) } @@ -80,14 +105,102 @@ struct CategoryDetailView: View { } } + // MARK: - Filter Bar + + private var filterBar: some View { + HStack(spacing: 12) { + // Search field + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.pmTextMuted) + .font(.system(size: 12)) + TextField("Search files...", text: $filter.searchText) + .textFieldStyle(.plain) + .font(.pmBody) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.pmCard) + .cornerRadius(8) + .frame(maxWidth: .infinity) + + // Size Filter + filterMenu(title: filter.sizeFilter == .all ? "Size" : filter.sizeFilter.rawValue, icon: "line.3.horizontal.decrease.circle") { + Picker("Size", selection: $filter.sizeFilter) { + ForEach(SizeFilter.allCases) { f in + Text(f.rawValue).tag(f) + } + } + } + + // Date Filter + filterMenu(title: filter.dateFilter == .all ? "Date" : filter.dateFilter.rawValue, icon: "calendar") { + Picker("Date", selection: $filter.dateFilter) { + ForEach(DateFilter.allCases) { f in + Text(f.rawValue).tag(f) + } + } + } + + // Sort + Menu { + Picker("Sort By", selection: $filter.sortBy) { + ForEach(SortBy.allCases) { s in + Text(s.rawValue).tag(s) + } + } + Divider() + Button(action: { filter.sortOrder = .ascending }) { + HStack { + Text("Ascending") + if filter.sortOrder == .ascending { Image(systemName: "checkmark") } + } + } + Button(action: { filter.sortOrder = .descending }) { + HStack { + Text("Descending") + if filter.sortOrder == .descending { Image(systemName: "checkmark") } + } + } + } label: { + Image(systemName: "arrow.up.arrow.down") + .font(.system(size: 12)) + .foregroundColor(.pmTextSecondary) + .padding(8) + .background(Color.pmCard) + .cornerRadius(8) + } + .menuStyle(.plain) + } + } + + private func filterMenu(title: String, icon: String, @ViewBuilder content: () -> Content) -> some View { + Menu { + content() + } label: { + HStack(spacing: 4) { + Image(systemName: icon) + Text(title) + } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.pmTextSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.pmCard) + .cornerRadius(8) + } + .menuStyle(.plain) + } + // MARK: - File List private func fileList(_ result: CategoryResult) -> some View { VStack(spacing: 0) { // Select all / Deselect all bar HStack(spacing: 16) { - let selectedCount = vm.selectedCountInCategory(category) - let totalCount = result.itemCount + let items = filteredItems + let selectedCount = items.filter { vm.isItemSelected($0) }.count + let totalCount = items.count Text("\(selectedCount) of \(totalCount) selected") .font(.system(size: 11, weight: .medium, design: .rounded)) @@ -96,7 +209,7 @@ struct CategoryDetailView: View { Spacer() Button("Select All") { - vm.selectAllInCategory(category) + vm.selectItems(items) } .font(.system(size: 11, weight: .medium)) .foregroundColor(.pmAccentLight) @@ -107,19 +220,11 @@ struct CategoryDetailView: View { .font(.system(size: 11)) Button("Deselect All") { - vm.deselectAllInCategory(category) + vm.deselectItems(items) } .font(.system(size: 11, weight: .medium)) .foregroundColor(.pmAccentLight) .buttonStyle(.plain) - - Button(action: { sortDescending.toggle() }) { - Image(systemName: "arrow.up.arrow.down") - .font(.system(size: 14)) - .foregroundColor(.pmAccentLight) - } - .buttonStyle(.plain) - .help(sortDescending ? "Sorted: Largest First" : "Sorted: Smallest First") } .padding(.horizontal, 48) .padding(.vertical, 8) @@ -130,7 +235,7 @@ struct CategoryDetailView: View { ScrollView { LazyVStack(spacing: 4) { - ForEach(sortedItems(result.items)) { item in + ForEach(filteredItems) { item in FileRow(item: item, color: category.color) } } @@ -184,12 +289,6 @@ struct CategoryDetailView: View { } } - // MARK: - Sort - - private func sortedItems(_ items: [CleanableItem]) -> [CleanableItem] { - items.sorted { sortDescending ? $0.size > $1.size : $0.size < $1.size } - } - // MARK: - Action Bar private var categoryActionBar: some View {