0af8e648fe
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>
339 lines
12 KiB
Swift
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)
|
|
}
|