Files
Spanish/Conjuga/Conjuga/Views/Verbs/VerbListView.swift
T
Trey T 0af8e648fe Vocab Practice crash — defensive Dictionary init + correct tense count
Crash: Swift/NativeDictionary.swift:792 Fatal error: Duplicate values
for key: 'imp_tú', triggered the first time a user rated a vocab card
and the in-flight example generation tried to materialise.

Root cause: VerbExampleGenerator.generate() builds a [tenseId: example]
dictionary from the model's output via Dictionary(uniqueKeysWithValues:),
which traps on duplicates. The generator's @Generable schema declares
@Guide(.count(6)) on the examples array, so the LLM is forced to return
exactly 6. The new Vocab Flashcards / Multiple Choice views called
generate(... tenseIds: ["ind_presente"]) — only one tense — which left
the model to invent the other 5 tenseIds; it duplicated 'imp_tú' and
the dictionary init trapped.

Three fixes:

  Services/VerbExampleGenerator.swift — use Dictionary(_:uniquingKeysWith:)
  with first-wins so the generator can never crash regardless of caller
  shape.

  Views/Practice/Vocab/VocabFlashcardPracticeView.swift and
  Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift — pass
  the canonical 6-tense set (VocabExampleTenseIds.canonical, same as
  VerbDetailView uses), then pick the ind_presente example for the
  card. Caches all six in VerbExampleCache as a side effect.

  Views/Verbs/VerbListView.swift — replace empty-string systemImage
  on the Level menu Labels with "circle" so the device console isn't
  spammed with "No symbol named '' found in system symbol set" every
  time the user opens the filter menu.

The crash analysis I gave earlier (CloudKit schema migration of
VerbReviewCard) was wrong — device console shows the real culprit. No
CloudKit-side changes needed; the new model stays in the cloud
container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:02:34 -05:00

339 lines
12 KiB
Swift

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
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var verbs: [Verb] = []
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
@State private var searchText = ""
@State private var progress: UserProgress?
@State private var selectedIrregularity: IrregularityCategory?
@State private var reflexiveOnly: Bool = false
@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] {
var result = verbs
if !allLevelsActive {
let activeRaws = Set(selectedLevels.map(\.rawValue))
result = result.filter { verb in
activeRaws.contains { VerbLevelGroup.matches(verb.level, selectedLevel: $0) }
}
}
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 reflexiveOnly {
result = result.filter { reflexiveStore.isReflexive(baseInfinitive: $0.infinitive) }
}
if !searchText.isEmpty {
let query = searchText.lowercased()
result = result.filter {
$0.infinitive.lowercased().contains(query) ||
$0.english.lowercased().contains(query)
}
}
return result
}
private let levels: [VerbLevel] = VerbLevel.allCases
var body: some View {
NavigationSplitView {
List(filteredVerbs, selection: $selectedVerb) { verb in
NavigationLink(value: 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 {
Section("Level") {
Button {
setAllLevels(enabled: true)
} label: {
Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "circle")
}
ForEach(levels, id: \.self) { level in
Button {
toggleLevel(level)
} label: {
Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "circle")
}
}
}
Section("Irregularity") {
Button {
selectedIrregularity = nil
} label: {
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "circle")
}
ForEach(IrregularityCategory.allCases) { category in
Button {
selectedIrregularity = category
} label: {
Label(category.rawValue, systemImage: selectedIrregularity == category ? "checkmark" : category.systemImage)
}
}
}
Section("Reflexive") {
Button {
reflexiveOnly.toggle()
} label: {
Label("Reflexive verbs only", systemImage: reflexiveOnly ? "checkmark" : "arrow.triangle.2.circlepath")
}
}
} label: {
Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
}
}
}
.task {
loadVerbs()
loadProgress()
}
.onAppear {
loadVerbs()
loadProgress()
}
} detail: {
if let verb = selectedVerb {
VerbDetailView(verb: verb)
} else {
ContentUnavailableView("Select a Verb", systemImage: "textformat.abc", description: Text("Choose a verb from the list to see its conjugations."))
}
}
}
private var hasActiveFilter: Bool {
!allLevelsActive || selectedIrregularity != nil || reflexiveOnly
}
@ViewBuilder
private var activeFilterBar: some View {
HStack(spacing: 8) {
if !allLevelsActive {
filterChip(text: levelChipLabel, systemImage: "graduationcap") {
setAllLevels(enabled: true)
}
}
if let cat = selectedIrregularity {
filterChip(text: cat.rawValue, systemImage: cat.systemImage) {
selectedIrregularity = nil
}
}
if reflexiveOnly {
filterChip(text: "Reflexive", systemImage: "arrow.triangle.2.circlepath") {
reflexiveOnly = false
}
}
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 {
print("[VerbListView] ⚠️ SharedStore.localContainer is nil")
return
}
if let url = SharedStore.localStoreURL() {
StoreInspector.dump(at: url, label: "verb-list-load")
}
let context = ModelContext(container)
verbs = ReferenceStore(context: context).fetchVerbs()
irregularityByVerbId = buildIrregularityIndex(context: context)
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>] {
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 {
VStack(alignment: .leading, spacing: 2) {
Text(verb.infinitive)
.font(.headline)
Text(verb.english)
.font(.subheadline)
.foregroundStyle(.secondary)
}
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)
.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
}
}
private func levelColor(_ level: String) -> Color {
switch level {
case "basic": return .green
case "elementary": return .blue
case "intermediate": return .orange
case "advanced": return .red
case "expert": return .purple
default: return .gray
}
}
}
#Preview {
VerbListView()
.modelContainer(for: Verb.self, inMemory: true)
}