Verbs — share level filter with Practice via UserProgress

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) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-13 23:18:23 -05:00
parent d49eb38a6d
commit 164a0a1bb7
+73 -14
View File
@@ -22,19 +22,38 @@ enum IrregularityCategory: String, CaseIterable, Identifiable {
struct VerbListView: View { struct VerbListView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(ReflexiveVerbStore.self) private var reflexiveStore @Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var verbs: [Verb] = [] @State private var verbs: [Verb] = []
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:] @State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
@State private var searchText = "" @State private var searchText = ""
@State private var selectedLevel: String? @State private var progress: UserProgress?
@State private var selectedIrregularity: IrregularityCategory? @State private var selectedIrregularity: IrregularityCategory?
@State private var reflexiveOnly: Bool = false @State private var reflexiveOnly: Bool = false
@State private var selectedVerb: Verb? @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<VerbLevel> {
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] { private var filteredVerbs: [Verb] {
var result = verbs var result = verbs
if let level = selectedLevel { if !allLevelsActive {
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) } let activeRaws = Set(selectedLevels.map(\.rawValue))
result = result.filter { verb in
activeRaws.contains { VerbLevelGroup.matches(verb.level, selectedLevel: $0) }
}
} }
if let category = selectedIrregularity { if let category = selectedIrregularity {
result = result.filter { verb in result = result.filter { verb in
@@ -55,7 +74,7 @@ struct VerbListView: View {
return result return result
} }
private let levels = ["basic", "elementary", "intermediate", "advanced", "expert"] private let levels: [VerbLevel] = VerbLevel.allCases
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
@@ -76,15 +95,15 @@ struct VerbListView: View {
Menu { Menu {
Section("Level") { Section("Level") {
Button { Button {
selectedLevel = nil setAllLevels(enabled: true)
} label: { } label: {
Label("All Levels", systemImage: selectedLevel == nil ? "checkmark" : "") Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "")
} }
ForEach(levels, id: \.self) { level in ForEach(levels, id: \.self) { level in
Button { Button {
selectedLevel = level toggleLevel(level)
} label: { } 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() } .task {
.onAppear { loadVerbs() } loadVerbs()
loadProgress()
}
.onAppear {
loadVerbs()
loadProgress()
}
} detail: { } detail: {
if let verb = selectedVerb { if let verb = selectedVerb {
VerbDetailView(verb: verb) VerbDetailView(verb: verb)
@@ -128,15 +153,15 @@ struct VerbListView: View {
} }
private var hasActiveFilter: Bool { private var hasActiveFilter: Bool {
selectedLevel != nil || selectedIrregularity != nil || reflexiveOnly !allLevelsActive || selectedIrregularity != nil || reflexiveOnly
} }
@ViewBuilder @ViewBuilder
private var activeFilterBar: some View { private var activeFilterBar: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
if let level = selectedLevel { if !allLevelsActive {
filterChip(text: level.capitalized, systemImage: "graduationcap") { filterChip(text: levelChipLabel, systemImage: "graduationcap") {
selectedLevel = nil setAllLevels(enabled: true)
} }
} }
if let cat = selectedIrregularity { if let cat = selectedIrregularity {
@@ -192,6 +217,40 @@ struct VerbListView: View {
print("[VerbListView] loaded \(verbs.count) verbs, \(irregularityByVerbId.count) flagged irregular (container: \(ObjectIdentifier(container)))") 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<IrregularityCategory>] { private func buildIrregularityIndex(context: ModelContext) -> [Int: Set<IrregularityCategory>] {
let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? [] let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? []
var index: [Int: Set<IrregularityCategory>] = [:] var index: [Int: Set<IrregularityCategory>] = [:]