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:
@@ -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>] = [:]
|
||||||
|
|||||||
Reference in New Issue
Block a user