From 164a0a1bb7016f9d1115afb94adada6838b4e830 Mon Sep 17 00:00:00 2001 From: Trey T Date: Wed, 13 May 2026 23:18:23 -0500 Subject: [PATCH] =?UTF-8?q?Verbs=20=E2=80=94=20share=20level=20filter=20wi?= =?UTF-8?q?th=20Practice=20via=20UserProgress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VerbListView used a local @State selectedLevel that reset whenever the user left the tab and was disconnected from Practice's verb sourcing. PracticeSessionService reads UserProgress.selectedVerbLevels; the Verbs tab now does the same. Level menu becomes multi-select (toggle each level in/out, mirrors SettingsView's pattern). "All Levels" enables every level; tapping again on a level removes it from the active set. Picking "All" while everything is already on is a no-op. Filter logic loops over the active level set and admits a verb if any active level matches via VerbLevelGroup.matches. Active-filter chip shows "Basic", "Elementary", etc. when one level is on, or "N levels" when multiple are on. Tapping the chip resets to all-levels. Empty set is treated as "no filter applied" on the Verbs list, but setAllLevels(enabled: false) falls back to [.basic] rather than an empty set — Practice treats empty as "no verbs", so we guard against leaving the user with an empty pool. Practice already reads UserProgress.selectedVerbLevels, so changes made in the Verbs tab now flow into Practice automatically. No Practice-side code changes needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Conjuga/Views/Verbs/VerbListView.swift | 87 ++++++++++++++++--- 1 file changed, 73 insertions(+), 14 deletions(-) diff --git a/Conjuga/Conjuga/Views/Verbs/VerbListView.swift b/Conjuga/Conjuga/Views/Verbs/VerbListView.swift index ebcb7c8..8c5d4bd 100644 --- a/Conjuga/Conjuga/Views/Verbs/VerbListView.swift +++ b/Conjuga/Conjuga/Views/Verbs/VerbListView.swift @@ -22,19 +22,38 @@ enum IrregularityCategory: String, CaseIterable, Identifiable { struct VerbListView: View { @Environment(\.modelContext) private var modelContext + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(ReflexiveVerbStore.self) private var reflexiveStore @State private var verbs: [Verb] = [] @State private var irregularityByVerbId: [Int: Set] = [:] @State private var searchText = "" - @State private var selectedLevel: String? + @State private var progress: UserProgress? @State private var selectedIrregularity: IrregularityCategory? @State private var reflexiveOnly: Bool = false @State private var selectedVerb: Verb? + private var cloudContext: ModelContext { cloudModelContextProvider() } + + /// Levels currently enabled in `UserProgress` — the same set that drives + /// what Practice picks from. The Verbs tab reads and writes the same + /// state so changes here propagate to Practice and vice versa. + private var selectedLevels: Set { + progress?.selectedVerbLevels ?? [] + } + + /// True when the user has every available level enabled (or none, which + /// we treat as "no filter applied" on the Verbs list specifically). + private var allLevelsActive: Bool { + selectedLevels.isEmpty || selectedLevels.count == VerbLevel.allCases.count + } + private var filteredVerbs: [Verb] { var result = verbs - if let level = selectedLevel { - result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) } + if !allLevelsActive { + let activeRaws = Set(selectedLevels.map(\.rawValue)) + result = result.filter { verb in + activeRaws.contains { VerbLevelGroup.matches(verb.level, selectedLevel: $0) } + } } if let category = selectedIrregularity { result = result.filter { verb in @@ -55,7 +74,7 @@ struct VerbListView: View { return result } - private let levels = ["basic", "elementary", "intermediate", "advanced", "expert"] + private let levels: [VerbLevel] = VerbLevel.allCases var body: some View { NavigationSplitView { @@ -76,15 +95,15 @@ struct VerbListView: View { Menu { Section("Level") { Button { - selectedLevel = nil + setAllLevels(enabled: true) } label: { - Label("All Levels", systemImage: selectedLevel == nil ? "checkmark" : "") + Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "") } ForEach(levels, id: \.self) { level in Button { - selectedLevel = level + toggleLevel(level) } label: { - Label(level.capitalized, systemImage: selectedLevel == level ? "checkmark" : "") + Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "") } } } @@ -116,8 +135,14 @@ struct VerbListView: View { } } } - .task { loadVerbs() } - .onAppear { loadVerbs() } + .task { + loadVerbs() + loadProgress() + } + .onAppear { + loadVerbs() + loadProgress() + } } detail: { if let verb = selectedVerb { VerbDetailView(verb: verb) @@ -128,15 +153,15 @@ struct VerbListView: View { } private var hasActiveFilter: Bool { - selectedLevel != nil || selectedIrregularity != nil || reflexiveOnly + !allLevelsActive || selectedIrregularity != nil || reflexiveOnly } @ViewBuilder private var activeFilterBar: some View { HStack(spacing: 8) { - if let level = selectedLevel { - filterChip(text: level.capitalized, systemImage: "graduationcap") { - selectedLevel = nil + if !allLevelsActive { + filterChip(text: levelChipLabel, systemImage: "graduationcap") { + setAllLevels(enabled: true) } } if let cat = selectedIrregularity { @@ -192,6 +217,40 @@ struct VerbListView: View { print("[VerbListView] loaded \(verbs.count) verbs, \(irregularityByVerbId.count) flagged irregular (container: \(ObjectIdentifier(container)))") } + // MARK: - Level filter (shared with Practice via UserProgress) + + private var levelChipLabel: String { + let names = selectedLevels + .sorted { $0.rawValue < $1.rawValue } + .map(\.displayName) + if names.isEmpty { return "No levels" } + if names.count == 1 { return names[0] } + return "\(names.count) levels" + } + + private func loadProgress() { + progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext) + } + + private func toggleLevel(_ level: VerbLevel) { + guard let progress else { return } + let enabled = !progress.selectedVerbLevels.contains(level) + progress.setLevelEnabled(level, enabled: enabled) + try? cloudContext.save() + } + + private func setAllLevels(enabled: Bool) { + guard let progress else { return } + if enabled { + progress.selectedVerbLevels = Set(VerbLevel.allCases) + } else { + // Practice treats an empty set as "no verbs", so guard against + // leaving the user with nothing — keep at least `basic`. + progress.selectedVerbLevels = [.basic] + } + try? cloudContext.save() + } + private func buildIrregularityIndex(context: ModelContext) -> [Int: Set] { let spans = (try? context.fetch(FetchDescriptor())) ?? [] var index: [Int: Set] = [:]