From 877e699c56edc2b621b0af1d442f750da320afa0 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 12 Apr 2026 22:31:30 -0500 Subject: [PATCH] Add Spanish Suffixes grammar guide with card-based content layout Add comprehensive suffix reference (diminutives, augmentatives, verb endings, noun/adjective-forming, adverbs, pejoratives) as grammar note #21 under Word Building category. Refactor grammar detail rendering to group paragraphs with their examples into visual cards, replacing the flat wall-of-text layout. Suffix entries get pill-styled labels with compact inline examples. Bump courseDataVersion to 5. Co-Authored-By: Claude Opus 4.6 (1M context) --- Conjuga/Conjuga/Models/GrammarNote.swift | 212 +++++++++++++ Conjuga/Conjuga/Services/DataLoader.swift | 2 +- .../Views/Guide/GrammarNotesView.swift | 295 ++++++++++++++++-- 3 files changed, 482 insertions(+), 27 deletions(-) diff --git a/Conjuga/Conjuga/Models/GrammarNote.swift b/Conjuga/Conjuga/Models/GrammarNote.swift index 1c26d93..f93111c 100644 --- a/Conjuga/Conjuga/Models/GrammarNote.swift +++ b/Conjuga/Conjuga/Models/GrammarNote.swift @@ -28,6 +28,7 @@ struct GrammarNote: Identifiable { accentMarksStress, seConstructions, estarGerundProgressive, + spanishSuffixes, ] // MARK: - 1. Ser vs Estar @@ -887,4 +888,215 @@ struct GrammarNote: Identifiable { **Other verbs with gerunds:** While *estar* is the most common, other verbs can also combine with gerunds: *seguir* (to keep on), *continuar* (to continue), *andar* (to go around), *llevar* (duration). *Sigo estudiando español.* — I keep on studying Spanish. *Llevo tres años viviendo aquí.* — I've been living here for three years. """ ) + + // MARK: - 21. Spanish Suffixes + + private static let spanishSuffixes = GrammarNote( + id: "spanish-suffixes", + title: "Spanish Suffixes", + category: "Word Building", + body: """ + Spanish uses suffixes — endings added to root words — to change meaning, size, tone, or part of speech. Learning these patterns lets you decode unfamiliar words and build new ones from roots you already know. + + **Diminutives — smallness and affection** + + Diminutive suffixes make a noun feel smaller, cuter, or more endearing. They are extremely common in everyday speech. + + *-ito / -ita* — the most common diminutive. Expresses smallness or affection. + *perro → perrito* — little dog + *casa → casita* — little house + *momento → momentito* — just a moment + *abuela → abuelita* — grandma (affectionate) + + *-cito / -cita* — used after words ending in a consonant or -e. + *café → cafecito* — a nice little coffee + *pobre → pobrecito* — poor little thing + *amor → amorcito* — sweetheart + + *-illo / -illa* — a slightly less affectionate diminutive. + *chico → chiquillo* — little kid + *guerra → guerrilla* — small war / guerrilla + *pan → panecillo* — bread roll + + *-ico / -ica* — regional diminutive popular in Colombia, Cuba, and Costa Rica. + *rato → ratico* — a little while + *gato → gatico* — little cat + + **Augmentatives — bigness and intensity** + + Augmentative suffixes make nouns feel bigger, more intense, or sometimes clumsier. They often carry a slightly negative or humorous tone. + + *-ón / -ona* — big or intense, sometimes negative. + *silla → sillón* — armchair (big chair) + *soltar → solterón* — confirmed bachelor + *cabeza → cabezón* — big-headed / stubborn + + *-ote / -ota* — big, often clumsy or exaggerated. + *grande → grandote* — really big + *amigo → amigote* — a big buddy (casual) + *palabra → palabrota* — a swear word (a "big word") + + *-azo / -aza* — big; also means a blow or hit with something. + *perro → perrazo* — huge dog + *puño → puñetazo* — a punch + *gol → golazo* — an amazing goal + *codo → codazo* — an elbow jab + + *-udo / -uda* — having a lot of something. + *pelo → peludo* — hairy + *barba → barbudo* — bearded + *panza → panzudo* — big-bellied + + **Verb-related endings** + + These endings are built into the verb conjugation system and tell you when and how the action occurs. + + *-ando* — gerund for -ar verbs (happening now, equivalent to English "-ing"). + *hablar → hablando* — speaking + *caminar → caminando* — walking + *estudiar → estudiando* — studying + + *-iendo* — gerund for -er and -ir verbs. + *comer → comiendo* — eating + *vivir → viviendo* — living + *leer → leyendo* — reading (note the spelling change) + + *-ado* — past participle for -ar verbs (equivalent to English "-ed"). + *hablar → hablado* — spoken + *cerrar → cerrado* — closed + *cansar → cansado* — tired + + *-ido* — past participle for -er and -ir verbs. + *comer → comido* — eaten + *vivir → vivido* — lived + *dormir → dormido* — slept + + **Noun-forming suffixes** + + These suffixes turn verbs or adjectives into nouns, creating words for actions, results, places, and people. + + *-ción / -sión* — action or result, equivalent to English "-tion." + *actuar → acción* — action + *decidir → decisión* — decision + *comunicar → comunicación* — communication + *expresar → expresión* — expression + + *-miento* — action or result, similar to -ción. + *conocer → conocimiento* — knowledge + *sentir → sentimiento* — feeling + *mover → movimiento* — movement + + *-ería* — a shop, business, or place associated with something. + *pan → panadería* — bakery + *libro → librería* — bookstore + *zapato → zapatería* — shoe store + *carne → carnicería* — butcher shop + + *-ero / -era* — a person who does or makes something. + *pan → panadero* — baker + *cocina → cocinero* — cook + *carta → cartero* — mail carrier + *enfermo → enfermera* — nurse + + *-dor / -dora* — a person or thing that does something. + *jugar → jugador* — player + *trabajar → trabajador* — worker + *computar → computadora* — computer + *lavar → lavadora* — washing machine + + *-ista* — a person devoted to something (gender-neutral form). + *arte → artista* — artist + *diente → dentista* — dentist + *fútbol → futbolista* — soccer player + *piano → pianista* — pianist + + *-ura* — a quality or result. + *dulce → dulzura* — sweetness + *loco → locura* — madness + *abrir → abertura* — opening + *pintar → pintura* — painting / paint + + *-eza* — an abstract quality. + *bello → belleza* — beauty + *triste → tristeza* — sadness + *puro → pureza* — purity + *natural → naturaleza* — nature + + *-dad / -tad* — a quality, equivalent to English "-ty." + *libre → libertad* — liberty + *real → realidad* — reality + *feliz → felicidad* — happiness + *amigo → amistad* — friendship + + *-anza* — action or result. + *esperar → esperanza* — hope + *enseñar → enseñanza* — teaching + *confiar → confianza* — trust / confidence + + **Adjective-forming suffixes** + + These turn nouns or verbs into adjectives that describe qualities, origins, or tendencies. + + *-oso / -osa* — full of something, equivalent to English "-ous." + *peligro → peligroso* — dangerous + *hermosura → hermoso* — beautiful + *cariño → cariñoso* — affectionate + *éxito → exitoso* — successful + + *-ano / -ana* — relating to a place or group. + *México → mexicano* — Mexican + *América → americano* — American + *cristiano* — Christian + *humano* — human + + *-eño / -eña* — from a place. + *Panamá → panameño* — Panamanian + *Brasil → brasileño* — Brazilian + *isla → isleño* — islander + + *-ense* — from a place (often countries or cities). + *Costa Rica → costarricense* — Costa Rican + *Estados Unidos → estadounidense* — American (USA) + *Canadá → canadiense* — Canadian + + *-ble* — able to be, equivalent to English "-ble." + *posible* — possible + *increíble* — incredible + *responsable* — responsible + *agradable* — pleasant + + *-ivo / -iva* — tending toward, equivalent to English "-ive." + *actuar → activo* — active + *crear → creativo* — creative + *producir → productivo* — productive + + **Adverb and intensity suffixes** + + *-mente* — turns adjectives into adverbs, equivalent to English "-ly." Attach it to the feminine form of the adjective. + *rápida → rápidamente* — quickly + *fácil → fácilmente* — easily + *tranquila → tranquilamente* — calmly + *completa → completamente* — completely + + *-ísimo / -ísima* — absolute superlative, meaning "extremely" or "super." + *rápido → rapidísimo* — super fast + *bella → bellísima* — extremely beautiful + *mucho → muchísimo* — very very much + *grande → grandísimo* — enormous + + **Pejorative suffixes — negative or ugly tone** + + These suffixes add a negative, derogatory, or ugly connotation. + + *-ucho / -ucha* — ugly, run-down. + *casa → casucha* — a shack, a dump + *delgado → delgaducho* — scrawny + + *-aco / -aca* — ugly, derogatory. + *pájaro → pajarraco* — ugly bird + *libro → libraco* — a terrible book + + **Key insight:** Once you recognize root + suffix patterns, you can decode unfamiliar words. For example, *panadería* = *pan* (bread) + *-ero* (person) + *-ía* (place) = "place where the bread-person works" = bakery. The suffix system makes Spanish vocabulary highly predictable and composable. + """ + ) } diff --git a/Conjuga/Conjuga/Services/DataLoader.swift b/Conjuga/Conjuga/Services/DataLoader.swift index 635fde4..9b9195b 100644 --- a/Conjuga/Conjuga/Services/DataLoader.swift +++ b/Conjuga/Conjuga/Services/DataLoader.swift @@ -123,7 +123,7 @@ actor DataLoader { /// Re-seed course data if the version has changed (e.g. examples were added). /// Call this on every launch — it checks a version key and only re-seeds when needed. static func refreshCourseDataIfNeeded(container: ModelContainer) async { - let currentVersion = 4 // Bump this whenever course_data.json changes + let currentVersion = 5 // Bump this whenever course_data.json changes let key = "courseDataVersion" let shared = UserDefaults.standard diff --git a/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift b/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift index d3fcff3..f08a172 100644 --- a/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift +++ b/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift @@ -101,40 +101,148 @@ struct GrammarNoteDetailView: View { private struct FormattedGrammarBody: View { let content: String - private var lines: [GrammarLine] { - GrammarLine.parse(content) + private var sections: [GrammarSection] { + GrammarSection.group(GrammarLine.parse(content)) } var body: some View { - VStack(alignment: .leading, spacing: 10) { - ForEach(lines) { line in - switch line.kind { - case .paragraph(let text): - renderParagraph(text) - case .spanishExample(let spanish): - Text(spanish) - .font(.body.weight(.medium)) - .italic() - .padding(.leading, 12) - case .examplePair(let spanish, let english): - VStack(alignment: .leading, spacing: 3) { - Text(spanish) - .font(.body.weight(.medium)) - .italic() - Text(english) - .font(.callout) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 16) { + ForEach(sections) { section in + if section.heading == nil { + // Intro content before the first heading — no card + renderLines(section.lines) + } else { + // Headed section in a card + VStack(alignment: .leading, spacing: 10) { + Text(section.heading!) + .font(.headline) + + renderLines(section.lines) } - .padding(.leading, 12) - case .heading(let text): - Text(text) - .font(.headline) - .padding(.top, 6) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) } } } } + @ViewBuilder + private func renderLines(_ lines: [GrammarLine]) -> some View { + let blocks = ContentBlock.group(lines) + VStack(alignment: .leading, spacing: 12) { + ForEach(blocks) { block in + switch block.kind { + case .standalone(let line): + renderSingleLine(line) + case .exampleCard(let header, let examples): + renderExampleCard(header: header, examples: examples) + case .suffixCard(let suffix, let description, let examples): + renderSuffixCard(suffix: suffix, description: description, examples: examples) + } + } + } + } + + @ViewBuilder + private func renderSingleLine(_ line: GrammarLine) -> some View { + switch line.kind { + case .paragraph(let text): + renderParagraph(text) + case .spanishExample(let spanish): + Text(spanish) + .font(.body.weight(.medium)) + .italic() + .padding(.leading, 12) + case .examplePair(let spanish, let english): + VStack(alignment: .leading, spacing: 3) { + Text(spanish) + .font(.body.weight(.medium)) + .italic() + Text(english) + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.leading, 12) + case .heading, .suffixDef: + EmptyView() + } + } + + @ViewBuilder + private func renderExampleCard(header: GrammarLine?, examples: [GrammarLine]) -> some View { + VStack(alignment: .leading, spacing: 8) { + if let header, case .paragraph(let text) = header.kind { + renderParagraph(text) + } + + VStack(alignment: .leading, spacing: 6) { + ForEach(examples) { ex in + if case .examplePair(let spanish, let english) = ex.kind { + VStack(alignment: .leading, spacing: 2) { + Text(spanish) + .font(.body.weight(.medium)) + .italic() + Text(english) + .font(.callout) + .foregroundStyle(.secondary) + } + } else if case .spanishExample(let spanish) = ex.kind { + Text(spanish) + .font(.body.weight(.medium)) + .italic() + } + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10)) + } + + @ViewBuilder + private func renderSuffixCard(suffix: String, description: String, examples: [GrammarLine]) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(suffix) + .font(.subheadline.weight(.bold).monospaced()) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.tint.opacity(0.12), in: Capsule()) + + Text(description) + .font(.callout) + .foregroundStyle(.secondary) + } + + if !examples.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(examples) { ex in + if case .examplePair(let spanish, let english) = ex.kind { + HStack(spacing: 0) { + Text(spanish) + .font(.body.weight(.medium)) + .italic() + Text(" ") + Text(english) + .font(.callout) + .foregroundStyle(.secondary) + } + } else if case .spanishExample(let spanish) = ex.kind { + Text(spanish) + .font(.body.weight(.medium)) + .italic() + } + } + } + .padding(.leading, 8) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10)) + } + @ViewBuilder private func renderParagraph(_ text: String) -> some View { Text(parseInlineFormatting(text)) @@ -200,11 +308,20 @@ private struct GrammarLine: Identifiable { let id: Int let kind: Kind + var isExample: Bool { + switch kind { + case .examplePair, .spanishExample: return true + default: return false + } + } + enum Kind { case paragraph(String) case heading(String) case spanishExample(String) case examplePair(spanish: String, english: String) + /// A suffix definition line like `*-ito / -ita* — description` + case suffixDef(suffix: String, description: String) } static func parse(_ body: String) -> [GrammarLine] { @@ -255,7 +372,13 @@ private struct GrammarLine: Identifiable { let englishPart = String(line[dashRange.upperBound...]) .replacingOccurrences(of: "*", with: "") .trimmingCharacters(in: .whitespaces) - result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart))) + + // Detect suffix definitions: spanish part starts with "-" + if spanishPart.hasPrefix("-") { + result.append(GrammarLine(id: result.count, kind: .suffixDef(suffix: spanishPart, description: englishPart))) + } else { + result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart))) + } } else { // Just a Spanish example without translation let spanish = line.replacingOccurrences(of: "*", with: "") @@ -279,6 +402,126 @@ private struct GrammarLine: Identifiable { } } +// MARK: - Content Block Grouping + +/// Groups paragraphs with their trailing examples into visual cards. +/// Also handles suffix definitions as a special card type. +private struct ContentBlock: Identifiable { + let id: Int + + enum Kind { + /// A standalone line (paragraph with no examples, or orphaned example) + case standalone(GrammarLine) + /// A paragraph header followed by example pairs — rendered as a card + case exampleCard(header: GrammarLine?, examples: [GrammarLine]) + /// A suffix definition with its examples — rendered as a pill card + case suffixCard(suffix: String, description: String, examples: [GrammarLine]) + } + + let kind: Kind + + static func group(_ lines: [GrammarLine]) -> [ContentBlock] { + var result: [ContentBlock] = [] + var i = 0 + + while i < lines.count { + let line = lines[i] + + // Suffix definition: collect trailing examples + if case .suffixDef(let suffix, let desc) = line.kind { + var examples: [GrammarLine] = [] + var j = i + 1 + while j < lines.count, lines[j].isExample { + examples.append(lines[j]) + j += 1 + } + result.append(ContentBlock(id: result.count, kind: .suffixCard(suffix: suffix, description: desc, examples: examples))) + i = j + continue + } + + // Paragraph followed by examples: group into a card + if case .paragraph = line.kind { + var j = i + 1 + // Check if examples follow + if j < lines.count, lines[j].isExample { + var examples: [GrammarLine] = [] + while j < lines.count, lines[j].isExample { + examples.append(lines[j]) + j += 1 + } + result.append(ContentBlock(id: result.count, kind: .exampleCard(header: line, examples: examples))) + i = j + } else { + // Standalone paragraph + result.append(ContentBlock(id: result.count, kind: .standalone(line))) + i += 1 + } + continue + } + + // Orphaned examples (no preceding paragraph) — group into a card + if line.isExample { + var examples: [GrammarLine] = [] + var j = i + while j < lines.count, lines[j].isExample { + examples.append(lines[j]) + j += 1 + } + result.append(ContentBlock(id: result.count, kind: .exampleCard(header: nil, examples: examples))) + i = j + continue + } + + // Skip headings (handled at section level) + if case .heading = line.kind { + i += 1 + continue + } + + result.append(ContentBlock(id: result.count, kind: .standalone(line))) + i += 1 + } + return result + } +} + +// MARK: - Grammar Section Grouping + +private struct GrammarSection: Identifiable { + let id: Int + let heading: String? + let lines: [GrammarLine] + + static func group(_ lines: [GrammarLine]) -> [GrammarSection] { + var sections: [GrammarSection] = [] + var currentHeading: String? = nil + var currentLines: [GrammarLine] = [] + var sectionIndex = 0 + + for line in lines { + if case .heading(let text) = line.kind { + // Flush the previous section + if !currentLines.isEmpty || currentHeading != nil { + sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines)) + sectionIndex += 1 + currentLines = [] + } + currentHeading = text + } else { + currentLines.append(line) + } + } + + // Flush final section + if !currentLines.isEmpty || currentHeading != nil { + sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines)) + } + + return sections + } +} + // MARK: - Hashable/Equatable conformance for NavigationLink extension GrammarNote: Hashable, Equatable {