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:
@@ -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 }
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Button(level.capitalized) { selectedLevel = level }
|
||||
Section("Level") {
|
||||
Button {
|
||||
selectedLevel = nil
|
||||
} label: {
|
||||
Label("All Levels", systemImage: selectedLevel == nil ? "checkmark" : "")
|
||||
}
|
||||
ForEach(levels, id: \.self) { level in
|
||||
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,14 +207,39 @@ struct VerbRowView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(verb.level.prefix(3).uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(levelColor(verb.level).opacity(0.15))
|
||||
.foregroundStyle(levelColor(verb.level))
|
||||
.clipShape(Capsule())
|
||||
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)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(levelColor(verb.level).opacity(0.15))
|
||||
.foregroundStyle(levelColor(verb.level))
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user