From d9ddaa49028dbf9815f4202f909a30f046a8fc64 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 21 Apr 2026 23:29:46 -0500 Subject: [PATCH] =?UTF-8?q?Fixes=20#23=20=E2=80=94=20Add=20irregularity=20?= =?UTF-8?q?filter=20to=20the=20Verbs=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Conjuga/Views/Verbs/VerbListView.swift | 172 ++++++++++++++++-- 1 file changed, 158 insertions(+), 14 deletions(-) diff --git a/Conjuga/Conjuga/Views/Verbs/VerbListView.swift b/Conjuga/Conjuga/Views/Verbs/VerbListView.swift index 0930a06..7401e80 100644 --- a/Conjuga/Conjuga/Views/Verbs/VerbListView.swift +++ b/Conjuga/Conjuga/Views/Verbs/VerbListView.swift @@ -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] = [:] @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] { + let spans = (try? context.fetch(FetchDescriptor())) ?? [] + var index: [Int: Set] = [:] + 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 = [] 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 } }