Fixes #23 — Add irregularity filter to the Verbs list

The toolbar Filter menu now has two sections:
- Level (existing)
- Irregularity: Any Irregular / Spelling Change / Stem Change /
  Unique Irregular

Filters combine, so "Basic" + "Unique Irregular" narrows to the
foundational ser/ir/haber-class verbs. Categories are derived at load
time from existing IrregularSpan rows using the same spanType ranges
already used by PracticeSessionService (1xx spelling, 2xx stem, 3xx
unique), so no schema or data changes are required.

UI additions:
- Per-row icons (star / arrows / I-beam) show each verb's
  irregularity categories at a glance, tinted by type.
- When any filter is active, a chip bar appears under the search
  field showing the active filters (tap to clear) and the resulting
  verb count.
- Filter toolbar icon fills when any filter is applied.

Data coverage: 614 / 1750 verbs are flagged irregular — 411 spelling,
275 stem-change, 67 unique — consistent with canonical lists from
SpanishDict, Lawless Spanish, and Wikipedia.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-21 23:29:46 -05:00
parent cdf1e05c4c
commit d9ddaa4902

View File

@@ -2,11 +2,31 @@ import SwiftUI
import SwiftData
import SharedModels
enum IrregularityCategory: String, CaseIterable, Identifiable {
case anyIrregular = "Any Irregular"
case spelling = "Spelling Change"
case stemChange = "Stem Change"
case uniqueIrregular = "Unique Irregular"
var id: String { rawValue }
var systemImage: String {
switch self {
case .anyIrregular: "asterisk"
case .spelling: "character.cursor.ibeam"
case .stemChange: "arrow.triangle.2.circlepath"
case .uniqueIrregular: "star"
}
}
}
struct VerbListView: View {
@Environment(\.modelContext) private var modelContext
@State private var verbs: [Verb] = []
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
@State private var searchText = ""
@State private var selectedLevel: String?
@State private var selectedIrregularity: IrregularityCategory?
@State private var selectedVerb: Verb?
private var filteredVerbs: [Verb] {
@@ -14,6 +34,12 @@ struct VerbListView: View {
if let level = selectedLevel {
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) }
}
if let category = selectedIrregularity {
result = result.filter { verb in
guard let cats = irregularityByVerbId[verb.id] else { return false }
return category == .anyIrregular ? !cats.isEmpty : cats.contains(category)
}
}
if !searchText.isEmpty {
let query = searchText.lowercased()
result = result.filter {
@@ -30,20 +56,50 @@ struct VerbListView: View {
NavigationSplitView {
List(filteredVerbs, selection: $selectedVerb) { verb in
NavigationLink(value: verb) {
VerbRowView(verb: verb)
VerbRowView(verb: verb, irregularities: irregularityByVerbId[verb.id] ?? [])
}
}
.navigationTitle("Verbs")
.searchable(text: $searchText, prompt: "Search verbs...")
.safeAreaInset(edge: .top, spacing: 0) {
if hasActiveFilter {
activeFilterBar
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("All Levels") { selectedLevel = nil }
Section("Level") {
Button {
selectedLevel = nil
} label: {
Label("All Levels", systemImage: selectedLevel == nil ? "checkmark" : "")
}
ForEach(levels, id: \.self) { level in
Button(level.capitalized) { selectedLevel = level }
Button {
selectedLevel = level
} label: {
Label(level.capitalized, systemImage: selectedLevel == level ? "checkmark" : "")
}
}
}
Section("Irregularity") {
Button {
selectedIrregularity = nil
} label: {
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "")
}
ForEach(IrregularityCategory.allCases) { category in
Button {
selectedIrregularity = category
} label: {
Label(category.rawValue, systemImage: selectedIrregularity == category ? "checkmark" : category.systemImage)
}
}
}
} label: {
Label("Filter", systemImage: "line.3.horizontal.decrease.circle")
Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
}
}
}
@@ -58,6 +114,51 @@ struct VerbListView: View {
}
}
private var hasActiveFilter: Bool {
selectedLevel != nil || selectedIrregularity != nil
}
@ViewBuilder
private var activeFilterBar: some View {
HStack(spacing: 8) {
if let level = selectedLevel {
filterChip(text: level.capitalized, systemImage: "graduationcap") {
selectedLevel = nil
}
}
if let cat = selectedIrregularity {
filterChip(text: cat.rawValue, systemImage: cat.systemImage) {
selectedIrregularity = nil
}
}
Spacer()
Text("\(filteredVerbs.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
private func filterChip(text: String, systemImage: String, onClear: @escaping () -> Void) -> some View {
Button(action: onClear) {
HStack(spacing: 4) {
Image(systemName: systemImage)
.font(.caption2)
Text(text)
.font(.caption.weight(.medium))
Image(systemName: "xmark")
.font(.caption2)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.blue.opacity(0.15), in: Capsule())
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
}
private func loadVerbs() {
// Hit the shared local container directly, bypassing @Environment.
guard let container = SharedStore.localContainer else {
@@ -69,12 +170,30 @@ struct VerbListView: View {
}
let context = ModelContext(container)
verbs = ReferenceStore(context: context).fetchVerbs()
print("[VerbListView] loaded \(verbs.count) verbs (container: \(ObjectIdentifier(container)))")
irregularityByVerbId = buildIrregularityIndex(context: context)
print("[VerbListView] loaded \(verbs.count) verbs, \(irregularityByVerbId.count) flagged irregular (container: \(ObjectIdentifier(container)))")
}
private func buildIrregularityIndex(context: ModelContext) -> [Int: Set<IrregularityCategory>] {
let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? []
var index: [Int: Set<IrregularityCategory>] = [:]
for span in spans {
let category: IrregularityCategory
switch span.spanType {
case 100..<200: category = .spelling
case 200..<300: category = .stemChange
case 300..<400: category = .uniqueIrregular
default: continue
}
index[span.verbId, default: []].insert(category)
}
return index
}
}
struct VerbRowView: View {
let verb: Verb
var irregularities: Set<IrregularityCategory> = []
var body: some View {
HStack {
@@ -88,6 +207,15 @@ struct VerbRowView: View {
Spacer()
HStack(spacing: 4) {
ForEach(orderedIrregularities, id: \.self) { cat in
Image(systemName: cat.systemImage)
.font(.caption2.weight(.semibold))
.foregroundStyle(irregularityColor(cat))
.help(cat.rawValue)
.accessibilityLabel(cat.rawValue)
}
Text(verb.level.prefix(3).uppercased())
.font(.caption2)
.fontWeight(.semibold)
@@ -98,6 +226,22 @@ struct VerbRowView: View {
.clipShape(Capsule())
}
}
}
private var orderedIrregularities: [IrregularityCategory] {
// Order: unique > stem > spelling (most notable first)
let order: [IrregularityCategory] = [.uniqueIrregular, .stemChange, .spelling]
return order.filter { irregularities.contains($0) }
}
private func irregularityColor(_ category: IrregularityCategory) -> Color {
switch category {
case .uniqueIrregular: return .purple
case .stemChange: return .orange
case .spelling: return .teal
case .anyIrregular: return .gray
}
}
private func levelColor(_ level: String) -> Color {
switch level {