diff --git a/CForge/Domain/ProblemFilterEngine.swift b/CForge/Domain/ProblemFilterEngine.swift index f8883a1..ecf5ca0 100644 --- a/CForge/Domain/ProblemFilterEngine.swift +++ b/CForge/Domain/ProblemFilterEngine.swift @@ -4,7 +4,8 @@ struct ProblemFilterEngine { static func filter( problems: [Problem], query: String, - selectedTag: String? + selectedTag: String?, + ratingRange: RatingRange? = nil ) -> [Problem] { let queryFiltered: [Problem] @@ -27,12 +28,25 @@ struct ProblemFilterEngine { } } - guard let tag = selectedTag else { - return queryFiltered + let tagFiltered: [Problem] + if let tag = selectedTag { + tagFiltered = queryFiltered.filter { problem in + problem.tags.contains(tag) + } + } else { + tagFiltered = queryFiltered } - - return queryFiltered.filter { problem in - problem.tags.contains(tag) + + guard let ratingRange = ratingRange else { + return tagFiltered + } + + return tagFiltered.filter { problem in + guard let rating = problem.rating else { + return false + } + + return ratingRange.contains(rating) } } } diff --git a/CForge/Domain/RatingRange.swift b/CForge/Domain/RatingRange.swift new file mode 100644 index 0000000..7b6ab5b --- /dev/null +++ b/CForge/Domain/RatingRange.swift @@ -0,0 +1,44 @@ +enum RatingRange: String, CaseIterable, Identifiable { + case r800to1000 + case r1000to1200 + case r1200to1400 + case r1400to1600 + case r1600to1800 + case r1800Plus + + var id: String { rawValue } + + var label: String { + switch self { + case .r800to1000: + return "800-999" + case .r1000to1200: + return "1000-1199" + case .r1200to1400: + return "1200-1399" + case .r1400to1600: + return "1400-1599" + case .r1600to1800: + return "1600-1799" + case .r1800Plus: + return "1800+" + } + } + + func contains(_ rating: Int) -> Bool { + switch self { + case .r800to1000: + return rating >= 800 && rating < 1000 + case .r1000to1200: + return rating >= 1000 && rating < 1200 + case .r1200to1400: + return rating >= 1200 && rating < 1400 + case .r1400to1600: + return rating >= 1400 && rating < 1600 + case .r1600to1800: + return rating >= 1600 && rating < 1800 + case .r1800Plus: + return rating >= 1800 + } + } +} diff --git a/CForge/ViewModels/ProblemListViewModel.swift b/CForge/ViewModels/ProblemListViewModel.swift index 15700cb..a9c9451 100644 --- a/CForge/ViewModels/ProblemListViewModel.swift +++ b/CForge/ViewModels/ProblemListViewModel.swift @@ -48,7 +48,7 @@ final class ProblemListViewModel: ObservableObject { } } - func filterProblems(query: String, tag: String?) { + func filterProblems(query: String, tag: String?, ratingRange: RatingRange? = nil) { filterTask?.cancel() let sourceProblems = allProblems @@ -57,7 +57,8 @@ final class ProblemListViewModel: ObservableObject { let filtered = ProblemFilterEngine.filter( problems: sourceProblems, query: query, - selectedTag: tag + selectedTag: tag, + ratingRange: ratingRange ) if Task.isCancelled { return } diff --git a/CForge/Views/Problem/ProblemListView.swift b/CForge/Views/Problem/ProblemListView.swift index 819c9f8..a859700 100644 --- a/CForge/Views/Problem/ProblemListView.swift +++ b/CForge/Views/Problem/ProblemListView.swift @@ -4,8 +4,9 @@ import WebKit struct ProblemListView: View { @StateObject private var viewModel = ProblemListViewModel() - @State internal var searchText = "" - @State internal var selectedTag: String? + @State private var searchText = "" + @State private var selectedTag: String? + @State private var selectedRatingRange: RatingRange? @@ -49,7 +50,14 @@ struct ProblemListView: View { tagFilterBar .padding(.bottom, 8) - + + Text("Rating") + .font(.caption2) + .foregroundColor(.textSecondary) + .padding(.horizontal) + ratingFilterBar + .padding(.bottom, 8) + ForEach(viewModel.filteredProblems) { problem in ProblemRow(problem: problem) } @@ -79,12 +87,15 @@ struct ProblemListView: View { .task { await viewModel.loadProblems() } .task(id: searchText) { do { - try await Task.sleep(nanoseconds: 300_000_000) - viewModel.filterProblems(query: searchText, tag: selectedTag) + try await Task.sleep(nanoseconds: 300_000_000) + viewModel.filterProblems(query: searchText, tag: selectedTag, ratingRange: selectedRatingRange) } catch {} } .task(id: selectedTag) { - viewModel.filterProblems(query: searchText, tag: selectedTag) + viewModel.filterProblems(query: searchText, tag: selectedTag, ratingRange: selectedRatingRange) + } + .task(id: selectedRatingRange) { + viewModel.filterProblems(query: searchText, tag: selectedTag, ratingRange: selectedRatingRange) } } } @@ -132,7 +143,6 @@ struct ProblemListView: View { ForEach(Array(Set(problems.flatMap { $0.tags })).sorted(), id: \.self) { tag in Button(action: { selectedTag = selectedTag == tag ? nil : tag - viewModel.filterProblems(query: searchText, tag: selectedTag) }) { Text(tag.capitalized) .font(.caption) @@ -172,6 +182,51 @@ struct ProblemListView: View { } } + private var ratingFilterBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(RatingRange.allCases) { ratingRange in + Button(action: { + selectedRatingRange = selectedRatingRange == ratingRange ? nil : ratingRange + }) { + Text(ratingRange.label) + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + ZStack { + if selectedRatingRange == ratingRange { + LinearGradient( + colors: [.neonBlue, .neonPurple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } else { + Color.darkerBackground + } + } + ) + .foregroundColor(selectedRatingRange == ratingRange ? .white : .primary) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + LinearGradient( + colors: [.neonBlue.opacity(0.4), .neonPurple.opacity(0.4)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 1 + ) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + } + // MARK: - Problem Row struct ProblemRow: View { let problem: Problem diff --git a/CForgeTests/ProblemFilterEngineRatingRangeTests.swift b/CForgeTests/ProblemFilterEngineRatingRangeTests.swift new file mode 100644 index 0000000..0de6b28 --- /dev/null +++ b/CForgeTests/ProblemFilterEngineRatingRangeTests.swift @@ -0,0 +1,118 @@ +import Testing +@testable import CForge + +struct ProblemFilterEngineRatingRangeTests { + + @Test func filterReturnsAllWhenRangeIsNil() { + let problems = [ + makeProblem(id: "rated", rating: 1000), + makeProblem(id: "unrated", rating: nil) + ] + + let filtered = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: nil + ) + + #expect(filtered == problems) + } + + @Test func filterDropsNilRatedProblemsWhenRangeIsActive() { + let problems = [ + makeProblem(id: "rated", rating: 1100), + makeProblem(id: "unrated", rating: nil) + ] + + let filtered = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: .r1000to1200 + ) + + #expect(filtered.map { $0.id } == ["rated"]) + } + + @Test func filterKeepsProblemsInRange() { + let problems = [ + makeProblem(id: "in-range", rating: 1100), + makeProblem(id: "out-of-range", rating: 800) + ] + + let filtered = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: .r1000to1200 + ) + + #expect(filtered.map { $0.id } == ["in-range"]) + } + + @Test func filterIncludesHighRatingsInR1800Plus() { + let problems = [ + makeProblem(id: "gm-level", rating: 3500), + makeProblem(id: "below", rating: 1799) + ] + + let filtered = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: .r1800Plus + ) + + #expect(filtered.map { $0.id } == ["gm-level"]) + } + + @Test func filterIncludesInclusiveLowerBound() { + let problems = [makeProblem(id: "lower", rating: 1000)] + + let filtered = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: .r1000to1200 + ) + + #expect(filtered.map { $0.id } == ["lower"]) + } + + @Test func filterExcludesUpperBound() { + let problems = [ + makeProblem(id: "upper-excluded", rating: 1200), + makeProblem(id: "upper-included-elsewhere", rating: 1200) + ] + + let inLow = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: .r1000to1200 + ) + + #expect(inLow.isEmpty) + + let inHigh = ProblemFilterEngine.filter( + problems: problems, + query: "", + selectedTag: nil, + ratingRange: .r1200to1400 + ) + + #expect(inHigh.count == 2) + } + + private func makeProblem(id: String, rating: Int?) -> Problem { + Problem( + id: id, + contestId: 1, + index: id, + title: "Problem \(id)", + rating: rating, + tags: [] + ) + } +}