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 } }