Compare commits

..

12 Commits

Author SHA1 Message Date
Trey T
5b69f3b630 Fixes #19 — Add English translations to exceptional yo form flashcards
Cards now show "tengo — I have" instead of just "tengo", so learners
see the English meaning alongside the Spanish yo form. Bumps course
data version to 6 to trigger re-seed on next launch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:02:40 -05:00
Trey t
ff4f906128 Fix crash from zero-length audio buffers in speech recognition
Guard against empty audio buffers before appending to speech
recognition request — AVAudioBuffer asserts non-zero data size.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:00:51 -05:00
23ff9d66de Merge pull request 'Expand grammar exercises to 100 sentences each' (#18) from feature/expand-grammar-exercises into main 2026-04-13 18:55:56 -05:00
Trey t
b48e935231 Expand grammar exercises to 100 sentences each, pick 10 random per session
- Ser vs Estar: 100 sentences
- Por vs Para: 100 sentences
- Preterite vs Imperfect: 100 sentences
- Subjunctive Triggers: 100 sentences
- Personal A: 100 sentences

Each session randomly selects 10 from the pool for variety.

Closes #15

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:55:36 -05:00
924090190f Merge pull request 'Add Done button to grammar exercise score screen' (#17) from fix/grammar-exercise-back-button into main 2026-04-13 18:49:37 -05:00
Trey t
945b2ff1f3 Add Done button to grammar exercise score screen
Fixes stuck state after completing grammar exercises — adds a
dismiss button on the results screen.

Closes #14

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:49:18 -05:00
77932f802a Merge pull request 'Fix listening practice crash on Start Speaking' (#16) from fix/listening-crash into main 2026-04-13 18:45:28 -05:00
Trey t
5944f263cd Fix listening practice crash when tapping Start Speaking
Wrap startRecording in do/catch so audio setup failures don't crash.
Validate recording format has channels before installTap. Use
DispatchQueue.main.async instead of Task{@MainActor} in recognition
callback to avoid dispatch queue assertions.

Closes #13

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:45:05 -05:00
Trey t
a3318adf5e Use ViewThatFits for study time and activity cards layout
Side by side on iPad, stacked vertically on iPhone. Fixes
calendar grid overflowing on narrow screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:28:02 -05:00
Trey t
a3807faf2d Fix speech authorization crash on device from dispatch queue assertion
Request authorization off main queue and marshal callback result back
via DispatchQueue.main.async. Check current status first to avoid
unnecessary system prompt if already authorized or denied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:19:03 -05:00
93ab7b3e16 Merge pull request 'Add 6 new practice features, offline dictionary, and feature reference' (#12) from newStuff into main 2026-04-13 16:13:15 -05:00
Trey t
a663bc03cd Add 6 new practice features, offline dictionary, and feature reference
New features:
- Offline Dictionary: reverse index of 175K verb forms + 200 common
  words, cached to disk, powers instant word lookups in Stories
- Vocab SRS Review: spaced repetition for course vocabulary cards
  with due count badge and Again/Hard/Good/Easy rating
- Cloze Practice: fill-in-the-blank using SentenceQuizEngine with
  distractor generation from vocabulary pool
- Grammar Exercises: interactive quizzes for 5 grammar topics
  (ser/estar, por/para, preterite/imperfect, subjunctive, personal a)
  with "Practice This" button on grammar note detail
- Listening Practice: listen-and-type + pronunciation check modes
  using Speech framework with word-by-word match scoring
- Conversational Practice: AI chat partner via Foundation Models
  with 10 scenario types, saved to cloud container

Other changes:
- Add Conversation model to SharedModels and cloud container
- Add Info.plist keys for speech recognition and microphone
- Skip speech auth on simulator to prevent crash
- Fix preparing data screen to only show during seed/migration
- Extract courseDataVersion to static property on DataLoader
- Add "How Features Work" reference page in Settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:12:36 -05:00
23 changed files with 2847 additions and 74 deletions

View File

@@ -83,6 +83,17 @@
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; };
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; };
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; };
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */; };
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3698CE7ACF148318615293E /* VocabReviewView.swift */; };
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A649B04B8B3C49419AD9219C /* ClozeView.swift */; };
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */; };
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */; };
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D535EF6988A24B47B70209A2 /* PronunciationService.swift */; };
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2179562E54E148C98219D /* ListeningView.swift */; };
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10603F454E54341AA4B9931 /* ConversationService.swift */; };
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */; };
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */; };
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -190,6 +201,17 @@
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -234,6 +256,7 @@
isa = PBXGroup;
children = (
BCCC95A95581458E068E0484 /* SettingsView.swift */,
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
);
path = Settings;
sourceTree = "<group>";
@@ -256,6 +279,9 @@
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
E10603F454E54341AA4B9931 /* ConversationService.swift */,
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
777C696A841803D5B775B678 /* ReferenceStore.swift */,
@@ -289,7 +315,8 @@
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */,
);
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */,
);
path = Models;
sourceTree = "<group>";
};
@@ -340,11 +367,15 @@
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
10C16AA6022E4742898745CE /* TypingView.swift */,
895E547BEFB5D0FBF676BE33 /* Lyrics */,
8A1DED0596E04DDE9536A9A9 /* Stories */,
);
DFD75E32A53845A693D98F48 /* Chat */,
02B2179562E54E148C98219D /* ListeningView.swift */,
A649B04B8B3C49419AD9219C /* ClozeView.swift */,
);
path = Practice;
sourceTree = "<group>";
};
@@ -353,9 +384,19 @@
children = (
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
);
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */,
);
path = Guide;
sourceTree = "<group>";
};
DFD75E32A53845A693D98F48 /* Chat */ = {
isa = PBXGroup;
children = (
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */,
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */,
);
path = Chat;
sourceTree = "<group>";
};
8A1DED0596E04DDE9536A9A9 /* Stories */ = {
isa = PBXGroup;
@@ -617,6 +658,17 @@
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */,
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */,
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */,
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */,
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */,
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */,
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */,
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */,
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */,
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */,
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */,
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */,
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */,
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -10,7 +10,7 @@ private enum CloudPreviewContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
return try! ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
configurations: configuration
)
}()
@@ -36,9 +36,10 @@ extension EnvironmentValues {
struct ConjugaApp: App {
@AppStorage("onboardingComplete") private var onboardingComplete = false
@Environment(\.scenePhase) private var scenePhase
@State private var isReady = false
@State private var isReady = true
@State private var syncMonitor = SyncStatusMonitor()
@State private var studyTimer = StudyTimerService()
@State private var dictionary = DictionaryService()
let localContainer: ModelContainer
let cloudContainer: ModelContainer
@@ -67,15 +68,16 @@ struct ConjugaApp: App {
"cloud",
schema: Schema([
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
]),
cloudKitDatabase: .private("iCloud.com.conjuga.app")
)
cloudContainer = try ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
configurations: cloudConfig
)
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
@@ -108,15 +110,22 @@ struct ConjugaApp: App {
.environment(syncMonitor)
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
.environment(studyTimer)
.environment(dictionary)
.task {
if let url = SharedStore.localStoreURL() {
StoreInspector.dump(at: url, label: "before-bootstrap")
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
if needsSeed {
isReady = false
}
await StartupCoordinator.bootstrap(localContainer: localContainer)
if let url = SharedStore.localStoreURL() {
StoreInspector.dump(at: url, label: "after-bootstrap")
if !isReady {
isReady = true
}
Task { @MainActor in
dictionary.buildIfNeeded(context: localContainer.mainContext)
}
isReady = true
Task { @MainActor in
syncMonitor.beginSync()
@@ -189,7 +198,7 @@ struct ConjugaApp: App {
deleteStoreFiles(at: url)
UserDefaults.standard.removeObject(forKey: "courseDataVersion")
print("Reset corrupted local reference store")
print("[ConjugaApp] ⚠️ Reset corrupted local reference store — this triggers full re-seed")
return try makeLocalContainer(at: url)
}
@@ -250,4 +259,5 @@ struct ConjugaApp: App {
defaults.set(resetVersion, forKey: key)
}
}

View File

@@ -24,6 +24,10 @@
<string>public.app-category.education</string>
<key>UILaunchScreen</key>
<dict/>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Conjuga uses speech recognition to check your Spanish pronunciation and transcribe what you say during listening exercises.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Conjuga needs microphone access to record your voice for pronunciation practice.</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>

View File

@@ -0,0 +1,569 @@
import Foundation
struct GrammarExercise: Identifiable, Hashable {
let id: String
let prompt: String
let sentence: String
let correctAnswer: String
let options: [String]
let explanation: String
static func exercises(for noteId: String) -> [GrammarExercise] {
switch noteId {
case "ser-vs-estar": return serVsEstarExercises
case "por-vs-para": return porVsParaExercises
case "preterite-vs-imperfect": return preteriteVsImperfectExercises
case "subjunctive-triggers": return subjunctiveTriggerExercises
case "personal-a": return personalAExercises
default: return []
}
}
// MARK: - Ser vs Estar (100)
private static let serVsEstarExercises: [GrammarExercise] = {
let data: [(String, String, String, String)] = [
// (sentence, correct, wrong, explanation)
("Ella _____ doctora.", "es", "está", "Ser for professions."),
("El libro _____ en la mesa.", "está", "es", "Estar for location."),
("Yo _____ muy cansado hoy.", "estoy", "soy", "Estar for temporary states."),
("Nosotros _____ de México.", "somos", "estamos", "Ser for origin."),
("La sopa _____ caliente.", "está", "es", "Estar for conditions."),
("_____ las tres de la tarde.", "Son", "Están", "Ser for telling time."),
("Mi hermano _____ alto.", "es", "está", "Ser for physical descriptions."),
("Ella _____ feliz porque aprobó.", "está", "es", "Estar for emotions."),
("La casa _____ grande.", "es", "está", "Ser for inherent qualities."),
("El café _____ frío.", "está", "es", "Estar for current condition."),
("Ellos _____ estudiantes.", "son", "están", "Ser for identity."),
("Yo _____ en la oficina.", "estoy", "soy", "Estar for location."),
("La fiesta _____ en mi casa.", "es", "está", "Ser for events (location of event)."),
("Tú _____ muy inteligente.", "eres", "estás", "Ser for personality traits."),
("El agua _____ fría.", "está", "es", "Estar for temperature (current state)."),
("María _____ de España.", "es", "está", "Ser for origin."),
("Nosotros _____ listos para salir.", "estamos", "somos", "Estar — ready (temporary state)."),
("Él _____ un buen amigo.", "es", "está", "Ser for characteristics."),
("La puerta _____ abierta.", "está", "es", "Estar for states resulting from actions."),
("Hoy _____ lunes.", "es", "está", "Ser for days/dates."),
("Yo _____ aburrido en clase.", "estoy", "soy", "Estar — bored (feeling now)."),
("Ella _____ aburrida como persona.", "es", "está", "Ser — boring (personality)."),
("La manzana _____ verde.", "está", "es", "Estar — unripe (condition)."),
("La camisa _____ de algodón.", "es", "está", "Ser for material."),
("Él _____ enfermo.", "está", "es", "Estar for health conditions."),
("Nosotros _____ contentos.", "estamos", "somos", "Estar for emotions."),
("La clase _____ a las ocho.", "es", "está", "Ser for scheduled time."),
("Tú _____ muy guapo hoy.", "estás", "eres", "Estar — looking good (today)."),
("Ella _____ profesora de español.", "es", "está", "Ser for profession."),
("El examen _____ difícil.", "es", "está", "Ser for inherent characteristic."),
("Yo _____ nervioso por el examen.", "estoy", "soy", "Estar for temporary feeling."),
("Los niños _____ en el parque.", "están", "son", "Estar for location."),
("La película _____ interesante.", "es", "está", "Ser for inherent quality."),
("El restaurante _____ cerrado.", "está", "es", "Estar for state (closed now)."),
("Mi padre _____ alto y moreno.", "es", "está", "Ser for physical description."),
("¿Dónde _____ el baño?", "está", "es", "Estar for location."),
("Ella _____ lista.", "es", "está", "Ser — clever (trait)."),
("¿Cómo _____ tú?", "estás", "eres", "Estar — how are you (state)."),
("La comida _____ deliciosa.", "está", "es", "Estar — tastes delicious (now)."),
("Él _____ colombiano.", "es", "está", "Ser for nationality."),
("Yo _____ preocupado.", "estoy", "soy", "Estar for worry (emotion)."),
("La mesa _____ de madera.", "es", "está", "Ser for material."),
("Ellos _____ cansados después del viaje.", "están", "son", "Estar for temporary state."),
("Mi madre _____ muy joven.", "es", "está", "Ser for age/appearance (inherent)."),
("El cielo _____ nublado.", "está", "es", "Estar for weather conditions."),
("Nosotros _____ hermanos.", "somos", "estamos", "Ser for relationships."),
("La ventana _____ rota.", "está", "es", "Estar for result of action."),
("¿Quién _____ tu profesor?", "es", "está", "Ser for identity."),
("El bebé _____ dormido.", "está", "es", "Estar for state (sleeping)."),
("Ella _____ muy trabajadora.", "es", "está", "Ser for personality."),
("Yo _____ listo para el examen.", "estoy", "soy", "Estar — ready."),
("La ciudad _____ bonita.", "es", "está", "Ser for inherent beauty."),
("Tú _____ sentado en mi silla.", "estás", "eres", "Estar for position/posture."),
("El problema _____ complicado.", "es", "está", "Ser for inherent quality."),
("La leche _____ en el refrigerador.", "está", "es", "Estar for location."),
("Yo _____ mexicano.", "soy", "estoy", "Ser for nationality."),
("Ella _____ embarazada.", "está", "es", "Estar for temporary condition."),
("La reunión _____ a las diez.", "es", "está", "Ser for scheduled time."),
("El perro _____ sucio.", "está", "es", "Estar for current condition."),
("Nosotros _____ amigos desde niños.", "somos", "estamos", "Ser for relationships."),
("Tú _____ muy callado hoy.", "estás", "eres", "Estar — quiet today (temporary)."),
("Ella _____ la directora.", "es", "está", "Ser for identity/role."),
("El coche _____ nuevo.", "es", "está", "Ser for characteristic."),
("Yo _____ seguro de eso.", "estoy", "soy", "Estar for certainty (state)."),
("La silla _____ rota.", "está", "es", "Estar for broken (result of action)."),
("Mi casa _____ cerca del parque.", "está", "es", "Estar for relative location."),
("Él _____ viejo.", "es", "está", "Ser for age."),
("El café _____ listo.", "está", "es", "Estar — ready (state)."),
("Nosotros _____ perdidos.", "estamos", "somos", "Estar for being lost."),
("La respuesta _____ correcta.", "es", "está", "Ser for fact."),
("Tú _____ enojado conmigo.", "estás", "eres", "Estar for emotion."),
("Ella _____ rica.", "es", "está", "Ser for wealth (inherent)."),
("El museo _____ en el centro.", "está", "es", "Estar for location."),
("Yo _____ de acuerdo.", "estoy", "soy", "Estar for agreement (state)."),
("La luz _____ encendida.", "está", "es", "Estar for state (on/off)."),
("Ellos _____ gemelos.", "son", "están", "Ser for identity."),
("El clima _____ agradable.", "está", "es", "Estar for weather now."),
("La tarea _____ para mañana.", "es", "está", "Ser for deadline."),
("Yo _____ ocupado ahora.", "estoy", "soy", "Estar for temporary state."),
("Ella _____ soltera.", "es", "está", "Ser for marital status."),
("El pan _____ duro.", "está", "es", "Estar for condition (stale)."),
("Mi hermana _____ mayor que yo.", "es", "está", "Ser for comparison."),
("Tú _____ mojado por la lluvia.", "estás", "eres", "Estar for condition."),
("La cena _____ a las nueve.", "es", "está", "Ser for time."),
("El hospital _____ lejos.", "está", "es", "Estar for distance/location."),
("Nosotros _____ orgullosos de ti.", "estamos", "somos", "Estar for emotion."),
("Ella _____ muy simpática.", "es", "está", "Ser for personality."),
("El gato _____ debajo de la cama.", "está", "es", "Estar for location."),
("Yo _____ vegetariano.", "soy", "estoy", "Ser for identity."),
("La ventana _____ sucia.", "está", "es", "Estar for condition."),
("Él _____ contento con su trabajo.", "está", "es", "Estar for satisfaction."),
("La prueba _____ fácil.", "es", "está", "Ser for inherent quality."),
("Tú _____ de buen humor.", "estás", "eres", "Estar for mood."),
("El vuelo _____ a las seis.", "es", "está", "Ser for scheduled time."),
("La playa _____ hermosa.", "es", "está", "Ser for inherent beauty."),
("Yo _____ emocionado por el viaje.", "estoy", "soy", "Estar for excitement."),
("Ellos _____ en casa.", "están", "son", "Estar for location."),
("La tienda _____ abierta.", "está", "es", "Estar for state."),
("Él _____ el mejor jugador.", "es", "está", "Ser for identity/superlative."),
("Nosotros _____ sorprendidos.", "estamos", "somos", "Estar for emotion."),
]
return data.enumerated().map { i, d in
GrammarExercise(id: "se\(i+1)", prompt: "Choose ser or estar:", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
}
}()
// MARK: - Por vs Para (100)
private static let porVsParaExercises: [GrammarExercise] = {
let data: [(String, String, String, String)] = [
("Este regalo es _____ ti.", "para", "por", "Para for recipient."),
("Gracias _____ tu ayuda.", "por", "para", "Por for cause/reason."),
("Caminamos _____ el parque.", "por", "para", "Por for movement through."),
("Estudio _____ aprender.", "para", "por", "Para for purpose."),
("Pagué veinte dólares _____ el libro.", "por", "para", "Por for exchange."),
("Salimos _____ Madrid mañana.", "para", "por", "Para for destination."),
("Estudié _____ dos horas.", "por", "para", "Por for duration."),
("Necesito el informe _____ el lunes.", "para", "por", "Para for deadline."),
("Te llamo _____ teléfono.", "por", "para", "Por for means."),
("Trabajo _____ una empresa grande.", "para", "por", "Para for employer."),
("Pasamos _____ tu casa ayer.", "por", "para", "Por for passing by."),
("La carta fue escrita _____ María.", "por", "para", "Por for agent in passive."),
("Este medicamento es _____ el dolor.", "para", "por", "Para for purpose."),
("Viajamos _____ avión.", "por", "para", "Por for means of transport."),
("_____ favor, ayúdame.", "Por", "Para", "Fixed expression: por favor."),
("Voy _____ agua.", "por", "para", "Por for going to get something."),
("_____ ser estudiante, habla muy bien.", "Para", "Por", "Para for comparison/considering."),
("Lo hice _____ ti.", "por", "para", "Por for on behalf of."),
("Este libro es _____ niños.", "para", "por", "Para for intended audience."),
("_____ supuesto que sí.", "Por", "Para", "Fixed expression: por supuesto."),
("Necesito lentes _____ leer.", "para", "por", "Para for purpose (in order to)."),
("Luchamos _____ la libertad.", "por", "para", "Por for cause worth fighting for."),
("Cambié mi coche _____ uno nuevo.", "por", "para", "Por for exchange."),
("Vamos _____ la costa.", "para", "por", "Para for destination."),
("_____ ejemplo, esto es fácil.", "Por", "Para", "Fixed expression: por ejemplo."),
("Mandé el paquete _____ correo.", "por", "para", "Por for means."),
("Compré flores _____ mi madre.", "para", "por", "Para for recipient."),
("Corrieron _____ la calle.", "por", "para", "Por for through/along."),
("Estudia mucho _____ sacar buenas notas.", "para", "por", "Para for goal."),
("_____ eso no vine.", "Por", "Para", "Por for reason (that's why)."),
("Ella trabaja _____ ganar dinero.", "para", "por", "Para for purpose."),
("Fueron criticados _____ los medios.", "por", "para", "Por for agent in passive."),
("Tengo un mensaje _____ usted.", "para", "por", "Para for recipient."),
("Votamos _____ el candidato.", "por", "para", "Por for in favor of."),
("_____ lo menos, intenta.", "Por", "Para", "Fixed expression: por lo menos."),
("La clase es _____ principiantes.", "para", "por", "Para for intended audience."),
("Pagamos mucho _____ la cena.", "por", "para", "Por for exchange."),
("Salgo _____ el aeropuerto a las cinco.", "para", "por", "Para for destination."),
("Esperamos _____ una hora.", "por", "para", "Por for duration."),
("_____ fin llegamos.", "Por", "Para", "Fixed expression: por fin."),
("¿_____ qué estudias español?", "Por", "Para", "Por qué — asking for reason."),
("¿_____ cuándo es el proyecto?", "Para", "Por", "Para for deadline."),
("Lo terminé _____ la noche.", "por", "para", "Por for time of day (general)."),
("Este dinero es _____ la renta.", "para", "por", "Para for purpose/intended use."),
("_____ mí, está bien.", "Para", "Por", "Para for opinion (in my view)."),
("Ella habla _____ todos nosotros.", "por", "para", "Por for on behalf of."),
("Voy a estar aquí _____ tres semanas.", "por", "para", "Por for duration."),
("Estas vitaminas son _____ la salud.", "para", "por", "Para for purpose."),
("Navegamos _____ el río.", "por", "para", "Por for along/through."),
("La tarea es _____ mañana.", "para", "por", "Para for deadline."),
("Fue elegido _____ el pueblo.", "por", "para", "Por for agent."),
("Estamos aquí _____ ayudarte.", "para", "por", "Para for purpose."),
("Me preocupo _____ mi familia.", "por", "para", "Por for concern about."),
("Hay una sorpresa _____ ti.", "para", "por", "Para for recipient."),
("_____ siempre te amaré.", "Para", "Por", "Fixed expression: para siempre."),
("Vendí el coche _____ cinco mil.", "por", "para", "Por for price/exchange."),
("Ella se fue _____ la mañana.", "por", "para", "Por for general time."),
("Este regalo es perfecto _____ ella.", "para", "por", "Para for recipient."),
("Brindamos _____ tu éxito.", "por", "para", "Por for toasting/in honor of."),
("Necesito un traje _____ la boda.", "para", "por", "Para for occasion."),
("Caminé _____ la playa al atardecer.", "por", "para", "Por for along."),
("_____ nada, fue un placer.", "De", "Para", "Actually 'de nada' — trick question. Skip."),
("Me quedé en casa _____ la lluvia.", "por", "para", "Por for cause."),
("Ahorro dinero _____ comprar una casa.", "para", "por", "Para for goal."),
("El tren pasa _____ aquí.", "por", "para", "Por for through/by here."),
("Tengo algo especial _____ ti.", "para", "por", "Para for recipient."),
("Lo dejé _____ después.", "para", "por", "Para for later (intended time)."),
("Murió _____ su país.", "por", "para", "Por for sacrifice/cause."),
("La reunión es _____ las dos.", "para", "por", "Para for deadline/scheduled."),
("Pregunté _____ ti en la fiesta.", "por", "para", "Por for asking about someone."),
("Estudio español _____ mi trabajo.", "para", "por", "Para for purpose."),
("_____ lo general, como a las doce.", "Por", "Para", "Fixed expression: por lo general."),
("Hice la comida _____ los invitados.", "para", "por", "Para for recipients."),
("Ella está aquí _____ unas semanas.", "por", "para", "Por for duration."),
("El avión sale _____ Buenos Aires.", "para", "por", "Para for destination."),
("Cambié euros _____ dólares.", "por", "para", "Por for exchange."),
("Corro _____ mantenerme en forma.", "para", "por", "Para for purpose."),
("Fueron aplaudidos _____ el público.", "por", "para", "Por for agent."),
("Ven _____ acá.", "para", "por", "Para for direction toward."),
("_____ suerte, no pasó nada.", "Por", "Para", "Fixed expression: por suerte."),
("Compré una torta _____ su cumpleaños.", "para", "por", "Para for occasion."),
("Viajé _____ toda Europa.", "por", "para", "Por for throughout."),
("El informe es _____ el director.", "para", "por", "Para for recipient."),
("Llegué tarde _____ el tráfico.", "por", "para", "Por for cause."),
("Está listo _____ servir.", "para", "por", "Para for readiness/purpose."),
("Doy gracias _____ todo.", "por", "para", "Por for gratitude about."),
("Este postre es _____ compartir.", "para", "por", "Para for intended use."),
("Fui al mercado _____ frutas.", "por", "para", "Por for going to fetch."),
("La canción fue compuesta _____ él.", "por", "para", "Por for agent."),
("Traje comida _____ todos.", "para", "por", "Para for recipients."),
("Nos fuimos _____ la puerta de atrás.", "por", "para", "Por for through/via."),
("Ella cocina _____ su familia.", "para", "por", "Para for beneficiary."),
("Dieron su vida _____ la patria.", "por", "para", "Por for sacrifice."),
("Tengo una cita _____ el miércoles.", "para", "por", "Para for deadline/date."),
("Lo hago _____ amor.", "por", "para", "Por for motivation."),
("_____ colmo, empezó a llover.", "Para", "Por", "Fixed expression: para colmo."),
("Mandamos invitaciones _____ correo.", "por", "para", "Por for means."),
("Vamos a brindar _____ los novios.", "por", "para", "Por for in honor of."),
("Reservé una mesa _____ cuatro personas.", "para", "por", "Para for intended use."),
]
return data.enumerated().map { i, d in
GrammarExercise(id: "pp\(i+1)", prompt: "Choose por or para:", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
}
}()
// MARK: - Preterite vs Imperfect (100)
private static let preteriteVsImperfectExercises: [GrammarExercise] = {
let data: [(String, String, String, String)] = [
("Ayer _____ una pizza. (comer, yo)", "comí", "comía", "Preterite — completed action (ayer)."),
("Cuando era niño, _____ en el parque. (jugar, yo)", "jugaba", "jugué", "Imperfect — habitual past action."),
("Ella _____ a las ocho. (llegar)", "llegó", "llegaba", "Preterite — single completed event."),
("_____ sol y los pájaros cantaban. (hacer)", "Hacía", "Hizo", "Imperfect — background description."),
("De repente, _____ el teléfono. (sonar)", "sonó", "sonaba", "Preterite — sudden event (de repente)."),
("Siempre _____ juntos los domingos. (comer, nosotros)", "comíamos", "comimos", "Imperfect — habitual (siempre)."),
("Ayer _____ al cine. (ir, nosotros)", "fuimos", "íbamos", "Preterite — specific completed action."),
("Cuando _____ joven, viajaba mucho. (ser, yo)", "era", "fui", "Imperfect — ongoing past state."),
("Anoche _____ una película muy buena. (ver, yo)", "vi", "veía", "Preterite — specific time (anoche)."),
("Todos los días _____ a la escuela. (caminar, ella)", "caminaba", "caminó", "Imperfect — habitual (todos los días)."),
("El año pasado _____ a España. (viajar, ellos)", "viajaron", "viajaban", "Preterite — specific time (el año pasado)."),
("Mientras yo _____, ella cocinaba. (estudiar)", "estudiaba", "estudié", "Imperfect — simultaneous background."),
("_____ las diez cuando llegamos. (ser)", "Eran", "Fueron", "Imperfect — time description."),
("Él _____ la puerta y salió. (abrir)", "abrió", "abría", "Preterite — sequential action."),
("De niña, _____ helado cada viernes. (comer, ella)", "comía", "comió", "Imperfect — habitual (de niña)."),
("_____ mucho frío ese día. (hacer)", "Hacía", "Hizo", "Imperfect — weather description."),
("Una vez, _____ a un famoso. (conocer, yo)", "conocí", "conocía", "Preterite — met for first time."),
("Yo _____ a Juan desde niño. (conocer)", "conocía", "conocí", "Imperfect — ongoing familiarity."),
("_____ la verdad ayer. (saber, yo)", "Supe", "Sabía", "Preterite — found out (new info)."),
("Yo _____ la verdad todo el tiempo. (saber)", "sabía", "supe", "Imperfect — knew (ongoing)."),
("Ella _____ un vestido azul. (llevar)", "llevaba", "llevó", "Imperfect — description of what she was wearing."),
("Él _____ el vaso y se rompió. (dejar caer)", "dejó caer", "dejaba caer", "Preterite — single event."),
("Generalmente _____ a las siete. (despertarse, yo)", "me despertaba", "me desperté", "Imperfect — habitual (generalmente)."),
("Esa noche _____ mucho. (llover)", "llovió", "llovía", "Preterite — bounded event (esa noche)."),
("_____ lloviendo cuando salí. (estar)", "Estaba", "Estuvo", "Imperfect — ongoing background."),
("Yo _____ cuando sonó la alarma. (dormir)", "dormía", "dormí", "Imperfect — interrupted background."),
("Ella _____ tres libros el verano pasado. (leer)", "leyó", "leía", "Preterite — counted completed actions."),
("Antes, _____ mucho café. (tomar, yo)", "tomaba", "tomé", "Imperfect — habitual (antes)."),
("El lunes _____ al médico. (ir, yo)", "fui", "iba", "Preterite — specific day."),
("Cada verano _____ a la playa. (ir, nosotros)", "íbamos", "fuimos", "Imperfect — habitual (cada verano)."),
("Él me _____ un secreto. (contar)", "contó", "contaba", "Preterite — single event."),
("Ella siempre me _____ historias. (contar)", "contaba", "contó", "Imperfect — habitual (siempre)."),
("_____ mucha gente en la fiesta. (haber)", "Había", "Hubo", "Imperfect — scene description."),
("_____ un accidente en la autopista. (haber)", "Hubo", "Había", "Preterite — single event."),
("Cuando _____ al parque, vi a Juan. (llegar, yo)", "llegué", "llegaba", "Preterite — completed action."),
("Mientras _____ al parque, vi a Juan. (caminar, yo)", "caminaba", "caminé", "Imperfect — ongoing when interrupted."),
("Ella _____ la guitarra de joven. (tocar)", "tocaba", "tocó", "Imperfect — used to (habitual)."),
("Ayer ella _____ la guitarra en el concierto. (tocar)", "tocó", "tocaba", "Preterite — specific event."),
("Mi abuela _____ muy bien. (cocinar)", "cocinaba", "cocinó", "Imperfect — description of ability."),
("Mi abuela _____ una paella ayer. (cocinar)", "cocinó", "cocinaba", "Preterite — specific completed action."),
("Yo _____ quince años cuando nos mudamos. (tener)", "tenía", "tuve", "Imperfect — age as background."),
("Él _____ un accidente terrible. (tener)", "tuvo", "tenía", "Preterite — single event."),
("Nosotros _____ en esa casa por diez años. (vivir)", "vivimos", "vivíamos", "Preterite — bounded duration (completed)."),
("Nosotros _____ en esa casa cuando era niño. (vivir)", "vivíamos", "vivimos", "Imperfect — ongoing past setting."),
("Ella _____ y se fue. (levantarse)", "se levantó", "se levantaba", "Preterite — sequential."),
("Ella _____ temprano cada mañana. (levantarse)", "se levantaba", "se levantó", "Imperfect — habitual."),
("¿Qué _____ cuando te llamé? (hacer, tú)", "hacías", "hiciste", "Imperfect — in progress when interrupted."),
("¿Qué _____ ayer después de clase? (hacer, tú)", "hiciste", "hacías", "Preterite — completed action."),
("El perro _____ todo el día. (ladrar)", "ladró", "ladraba", "Could be both — preterite bounds the whole day."),
("El perro _____ cuando llegó el cartero. (ladrar)", "ladraba", "ladró", "Imperfect — background action."),
("Yo _____ mucho en esa época. (trabajar)", "trabajaba", "trabajé", "Imperfect — ongoing past period."),
("Yo _____ allí por cinco años. (trabajar)", "trabajé", "trabajaba", "Preterite — completed bounded duration."),
("La tienda _____ a las nueve. (abrir)", "abrió", "abría", "Preterite — one-time event."),
("La tienda _____ a las nueve todos los días. (abrir)", "abría", "abrió", "Imperfect — habitual."),
("Él _____ el periódico cada mañana. (leer)", "leía", "leyó", "Imperfect — habitual."),
("Él _____ el periódico y luego desayunó. (leer)", "leyó", "leía", "Preterite — sequential."),
("_____ una noche oscura y fría. (ser)", "Era", "Fue", "Imperfect — scene setting."),
("_____ un día memorable. (ser)", "Fue", "Era", "Preterite — judgment about completed day."),
("Yo no _____ nada. (decir)", "dije", "decía", "Preterite — single action."),
("Ella siempre _____ la verdad. (decir)", "decía", "dijo", "Imperfect — habitual."),
("Los niños _____ en el jardín. (jugar)", "jugaban", "jugaron", "Imperfect — ongoing scene."),
("Los niños _____ toda la tarde. (jugar)", "jugaron", "jugaban", "Preterite — bounded duration."),
("Cuando _____ niño, mi padre me leía cuentos. (ser, yo)", "era", "fui", "Imperfect — background."),
("Él _____ presidente por ocho años. (ser)", "fue", "era", "Preterite — bounded duration."),
("_____ las seis de la mañana cuando desperté. (ser)", "Eran", "Fueron", "Imperfect — time."),
("_____ un buen año para la empresa. (ser)", "Fue", "Era", "Preterite — completed period judged."),
("Ella _____ triste cuando recibió la noticia. (ponerse)", "se puso", "se ponía", "Preterite — became (change of state)."),
("Ella _____ triste cada vez que llovía. (ponerse)", "se ponía", "se puso", "Imperfect — habitual reaction."),
("Yo _____ poder ir, pero no pude. (querer)", "quería", "quise", "Imperfect — wanted (ongoing desire)."),
("Él no _____ hacerlo. (querer)", "quiso", "quería", "Preterite — refused (completed decision)."),
("Nosotros _____ a la playa el domingo. (ir)", "fuimos", "íbamos", "Preterite — specific completed trip."),
("_____ a la playa cuando empezó a llover. (ir, nosotros)", "Íbamos", "Fuimos", "Imperfect — were going (interrupted)."),
("Ella _____ muy contenta en su nuevo trabajo. (estar)", "estaba", "estuvo", "Imperfect — ongoing state."),
("Ella _____ enferma toda la semana. (estar)", "estuvo", "estaba", "Preterite — bounded duration."),
("Mi abuelo _____ cuentos increíbles. (contar)", "contaba", "contó", "Imperfect — used to tell."),
("Esa vez mi abuelo nos _____ una historia de miedo. (contar)", "contó", "contaba", "Preterite — specific occasion."),
("Yo _____ en el sofá cuando oí un ruido. (estar)", "estaba", "estuve", "Imperfect — background when interrupted."),
("Ella _____ rápidamente y llamó al médico. (vestirse)", "se vistió", "se vestía", "Preterite — sequential."),
("A menudo _____ por el bosque. (caminar, nosotros)", "caminábamos", "caminamos", "Imperfect — habitual (a menudo)."),
("Esa tarde _____ por el bosque. (caminar, nosotros)", "caminamos", "caminábamos", "Preterite — specific occasion."),
("La profesora _____ muy estricta. (ser)", "era", "fue", "Imperfect — description."),
("La profesora _____ muy amable con nosotros ese día. (ser)", "fue", "era", "Preterite — specific day."),
("¿_____ mucho en tu ciudad natal? (llover)", "Llovía", "Llovió", "Imperfect — general weather pattern."),
("¿_____ ayer? (llover)", "Llovió", "Llovía", "Preterite — specific day."),
("El niño _____ porque tenía hambre. (llorar)", "lloraba", "lloró", "Imperfect — ongoing due to reason."),
("El niño _____ cuando se cayó. (llorar)", "lloró", "lloraba", "Preterite — reaction to event."),
("Yo _____ cocinar cuando era joven. (no saber)", "no sabía", "no supe", "Imperfect — ongoing lack."),
("Yo _____ cocinar hasta que tomé clases. (no saber)", "no supe", "no sabía", "Preterite — realized/found out."),
("Ella _____ la carta y empezó a llorar. (leer)", "leyó", "leía", "Preterite — completed then next action."),
("Él _____ cuando entré. (hablar)", "hablaba", "habló", "Imperfect — was speaking (interrupted)."),
("Nosotros _____ en ese restaurante muchas veces. (cenar)", "cenábamos", "cenamos", "Imperfect — habitual."),
("Nosotros _____ en ese restaurante anoche. (cenar)", "cenamos", "cenábamos", "Preterite — specific night."),
("_____ un día perfecto para ir a la playa. (ser)", "Era", "Fue", "Imperfect — description/setting."),
("Ella _____ la primera en llegar. (ser)", "fue", "era", "Preterite — completed fact."),
("Yo _____ en silencio mientras él hablaba. (escuchar)", "escuchaba", "escuché", "Imperfect — simultaneous."),
("Yo _____ todo su discurso. (escuchar)", "escuché", "escuchaba", "Preterite — listened to completion."),
("El tren _____ a las tres en punto. (salir)", "salió", "salía", "Preterite — specific departure."),
("El tren _____ a las tres todos los días. (salir)", "salía", "salió", "Imperfect — habitual schedule."),
("Ella _____ el piano maravillosamente. (tocar)", "tocaba", "tocó", "Imperfect — ability description."),
]
return data.enumerated().map { i, d in
GrammarExercise(id: "pi\(i+1)", prompt: "Choose the correct tense:", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
}
}()
// MARK: - Subjunctive Triggers (100)
private static let subjunctiveTriggerExercises: [GrammarExercise] = {
let data: [(String, String, String, String)] = [
("Quiero que _____ a la fiesta. (venir, tú)", "vengas", "vienes", "Subjunctive — querer (wish)."),
("Es necesario que _____ más. (estudiar, tú)", "estudies", "estudias", "Subjunctive — impersonal expression."),
("Sé que ella _____ aquí. (estar)", "está", "esté", "Indicative — saber (certainty)."),
("Me alegra que _____ aquí. (estar, tú)", "estés", "estás", "Subjunctive — emotion (alegrarse)."),
("Dudo que _____ la verdad. (decir, él)", "diga", "dice", "Subjunctive — doubt (dudar)."),
("Es posible que _____ mañana. (llover)", "llueva", "llueve", "Subjunctive — possibility."),
("Espero que _____ bien. (estar, tú)", "estés", "estás", "Subjunctive — hope (esperar)."),
("Creo que _____ razón. (tener, tú)", "tienes", "tengas", "Indicative — creer (belief)."),
("No creo que _____ razón. (tener, tú)", "tengas", "tienes", "Subjunctive — negated belief."),
("Es importante que _____ puntual. (ser, tú)", "seas", "eres", "Subjunctive — impersonal expression."),
("Ojalá que _____ buen tiempo. (hacer)", "haga", "hace", "Subjunctive — ojalá (wish)."),
("Te pido que _____ silencio. (guardar)", "guardes", "guardas", "Subjunctive — pedir (request)."),
("Es cierto que _____ mucho. (trabajar, ella)", "trabaja", "trabaje", "Indicative — es cierto (certainty)."),
("No es cierto que _____ mucho. (trabajar, ella)", "trabaje", "trabaja", "Subjunctive — negated certainty."),
("Prefiero que _____ tú. (conducir)", "conduzcas", "conduces", "Subjunctive — preferir (preference)."),
("Siento que no _____ venir. (poder, tú)", "puedas", "puedes", "Subjunctive — sentir (emotion)."),
("Es obvio que _____ cansado. (estar, él)", "está", "esté", "Indicative — es obvio (certainty)."),
("Necesito que me _____ un favor. (hacer, tú)", "hagas", "haces", "Subjunctive — necesitar que."),
("Es mejor que _____ temprano. (salir, nosotros)", "salgamos", "salimos", "Subjunctive — es mejor que."),
("Estoy seguro de que _____ bien. (ir, todo)", "va", "vaya", "Indicative — estar seguro (certainty)."),
("Temo que _____ demasiado tarde. (ser)", "sea", "es", "Subjunctive — temer (fear)."),
("Sugiero que _____ más agua. (beber, tú)", "bebas", "bebes", "Subjunctive — sugerir (suggestion)."),
("Es verdad que _____ difícil. (ser)", "es", "sea", "Indicative — es verdad (truth)."),
("No es verdad que _____ difícil. (ser)", "sea", "es", "Subjunctive — negated truth."),
("Quiero que _____ la puerta. (cerrar, tú)", "cierres", "cierras", "Subjunctive — querer."),
("Deseo que _____ feliz. (ser, tú)", "seas", "eres", "Subjunctive — desear (wish)."),
("Es probable que _____ tarde. (llegar, ellos)", "lleguen", "llegan", "Subjunctive — es probable."),
("Es improbable que _____ hoy. (nevar)", "nieve", "nieva", "Subjunctive — es improbable."),
("Me molesta que _____ tanto ruido. (hacer, ellos)", "hagan", "hacen", "Subjunctive — emotion (molestar)."),
("Es evidente que _____ talento. (tener, ella)", "tiene", "tenga", "Indicative — es evidente."),
("Recomiendo que _____ este libro. (leer, tú)", "leas", "lees", "Subjunctive — recomendar."),
("Exijo que _____ a tiempo. (llegar, todos)", "lleguen", "llegan", "Subjunctive — exigir (demand)."),
("Es una lástima que no _____ ir. (poder, tú)", "puedas", "puedes", "Subjunctive — es una lástima."),
("Me sorprende que _____ tan joven. (ser, él)", "sea", "es", "Subjunctive — surprise (emotion)."),
("Insisto en que _____ la verdad. (decir, tú)", "digas", "dices", "Subjunctive — insistir."),
("Es extraño que no _____ aquí. (estar, ella)", "esté", "está", "Subjunctive — es extraño."),
("Prohíbo que _____ en clase. (comer, ustedes)", "coman", "comen", "Subjunctive — prohibir."),
("Permito que _____ temprano. (salir, tú)", "salgas", "sales", "Subjunctive — permitir."),
("Es dudoso que _____ a tiempo. (terminar, nosotros)", "terminemos", "terminamos", "Subjunctive — es dudoso."),
("Pienso que _____ inteligente. (ser, ella)", "es", "sea", "Indicative — pensar (opinion)."),
("No pienso que _____ justo. (ser)", "sea", "es", "Subjunctive — negated opinion."),
("Me encanta que _____ español. (hablar, tú)", "hables", "hablas", "Subjunctive — emotion (encantar)."),
("Es fantástico que _____ aquí. (estar, ustedes)", "estén", "están", "Subjunctive — es fantástico."),
("Mando que _____ inmediatamente. (venir, tú)", "vengas", "vienes", "Subjunctive — mandar."),
("Es ridículo que _____ eso. (pensar, él)", "piense", "piensa", "Subjunctive — es ridículo."),
("Busco a alguien que _____ francés. (hablar)", "hable", "habla", "Subjunctive — nonexistent antecedent."),
("Conozco a alguien que _____ francés. (hablar)", "habla", "hable", "Indicative — known antecedent."),
("No hay nadie que _____ eso. (saber)", "sepa", "sabe", "Subjunctive — negative antecedent."),
("Cuando _____ a casa, llámame. (llegar, tú)", "llegues", "llegas", "Subjunctive — cuando + future."),
("Cuando _____ a casa, siempre como. (llegar, yo)", "llego", "llegue", "Indicative — cuando + habitual."),
("Antes de que _____, quiero decirte algo. (ir, tú)", "te vayas", "te vas", "Subjunctive — antes de que."),
("Después de que _____, descansaremos. (terminar, nosotros)", "terminemos", "terminamos", "Subjunctive — después de que + future."),
("Aunque _____ mucho, iré. (llover)", "llueva", "llueve", "Subjunctive — aunque + hypothetical."),
("Aunque _____ mucho, siempre voy. (llover)", "llueve", "llueva", "Indicative — aunque + factual."),
("Para que _____ bien, debes practicar. (salir, todo)", "salga", "sale", "Subjunctive — para que."),
("Sin que nadie lo _____. (saber)", "sepa", "sabe", "Subjunctive — sin que."),
("Con tal de que _____ contento. (estar, tú)", "estés", "estás", "Subjunctive — con tal de que."),
("A menos que _____ temprano, perderás el tren. (salir, tú)", "salgas", "sales", "Subjunctive — a menos que."),
("En caso de que _____, llámame. (necesitar, tú)", "necesites", "necesitas", "Subjunctive — en caso de que."),
("Mientras _____ aquí, todo estará bien. (estar, yo)", "esté", "estoy", "Subjunctive — mientras + uncertainty."),
("Tan pronto como _____, empezamos. (llegar, él)", "llegue", "llega", "Subjunctive — tan pronto como + future."),
("Hasta que no _____, no me voy. (terminar, tú)", "termines", "terminas", "Subjunctive — hasta que + future."),
("Es hora de que _____ la verdad. (saber, tú)", "sepas", "sabes", "Subjunctive — es hora de que."),
("Espero que _____ un buen día. (tener, tú)", "tengas", "tienes", "Subjunctive — esperar."),
("Dile que _____ aquí. (venir)", "venga", "viene", "Subjunctive — indirect command."),
("No hay nada que _____ hacer. (poder, yo)", "pueda", "puedo", "Subjunctive — negative existence."),
("Es normal que _____ nervioso. (estar, tú)", "estés", "estás", "Subjunctive — es normal que."),
("Me da miedo que _____ sola. (ir, ella)", "vaya", "va", "Subjunctive — emotion (dar miedo)."),
("Es urgente que _____ al doctor. (ir, tú)", "vayas", "vas", "Subjunctive — es urgente."),
("No quiero que _____ tarde. (llegar, tú)", "llegues", "llegas", "Subjunctive — no querer."),
("Tal vez _____ razón. (tener, tú)", "tengas", "tienes", "Subjunctive — tal vez."),
("Quizás _____ mañana. (venir, ella)", "venga", "viene", "Subjunctive — quizás."),
("Es imposible que _____ tan rápido. (terminar, él)", "termine", "termina", "Subjunctive — es imposible."),
("Parece que _____ contento. (estar, él)", "está", "esté", "Indicative — parece que (appears)."),
("No parece que _____ contento. (estar, él)", "esté", "está", "Subjunctive — negated parece."),
("Dice que _____ mañana. (venir)", "viene", "venga", "Indicative — decir reporting fact."),
("Dice que _____ mañana. (venir — as command)", "venga", "viene", "Subjunctive — decir as command."),
("Me preocupa que no _____ bien. (sentirse, tú)", "te sientas", "te sientes", "Subjunctive — emotion (preocupar)."),
("Es raro que _____ tanto calor. (hacer)", "haga", "hace", "Subjunctive — es raro."),
("Confío en que _____ bien. (salir, todo)", "salga", "sale", "Subjunctive — confiar en que."),
("Es fundamental que _____ la tarea. (hacer, ustedes)", "hagan", "hacen", "Subjunctive — es fundamental."),
("Me pone triste que _____ así. (ser, las cosas)", "sean", "son", "Subjunctive — emotion."),
("Aconsejo que _____ más temprano. (acostarse, tú)", "te acuestes", "te acuestas", "Subjunctive — aconsejar."),
("Es bueno que _____ ejercicio. (hacer, tú)", "hagas", "haces", "Subjunctive — es bueno que."),
("Es malo que _____ tanto. (fumar, él)", "fume", "fuma", "Subjunctive — es malo que."),
("Me gusta que _____ aquí. (estar, tú)", "estés", "estás", "Subjunctive — emotion (gustar que)."),
("No creo que _____ la respuesta. (saber, él)", "sepa", "sabe", "Subjunctive — negated belief."),
("Es increíble que _____ tan rápido. (aprender, ella)", "aprenda", "aprende", "Subjunctive — es increíble."),
("Ojala _____ más tiempo. (tener, nosotros)", "tengamos", "tenemos", "Subjunctive — ojalá."),
("Niego que _____ la verdad. (ser, eso)", "sea", "es", "Subjunctive — negar (deny)."),
("Es preciso que _____ ahora. (salir, nosotros)", "salgamos", "salimos", "Subjunctive — es preciso."),
("Te aconsejo que _____ paciencia. (tener)", "tengas", "tienes", "Subjunctive — aconsejar."),
("Basta que _____ una vez. (decir, tú)", "digas", "dices", "Subjunctive — bastar que."),
("Conviene que _____ preparado. (estar, tú)", "estés", "estás", "Subjunctive — convenir que."),
("Es natural que _____ preocupado. (estar, él)", "esté", "está", "Subjunctive — es natural."),
("Ruego que me _____. (perdonar, tú)", "perdones", "perdonas", "Subjunctive — rogar."),
("Es suficiente que _____ una carta. (escribir, tú)", "escribas", "escribes", "Subjunctive — es suficiente que."),
("Me fascina que _____ tantos idiomas. (hablar, ella)", "hable", "habla", "Subjunctive — emotion (fascinar)."),
("Hace falta que _____ más esfuerzo. (poner, nosotros)", "pongamos", "ponemos", "Subjunctive — hacer falta que."),
]
return data.enumerated().map { i, d in
GrammarExercise(id: "st\(i+1)", prompt: "Subjunctive or indicative?", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
}
}()
// MARK: - Personal A (100)
private static let personalAExercises: [GrammarExercise] = {
let data: [(String, String, String, String)] = [
("Veo _____ María.", "a", "(nothing)", "Personal a — specific person as direct object."),
("Veo _____ la mesa.", "(nothing)", "a", "No personal a — thing, not person."),
("Tengo _____ dos hermanos.", "(nothing)", "a", "No personal a after tener."),
("Conozco _____ tu profesor.", "a", "(nothing)", "Personal a — specific person."),
("Busco _____ un doctor.", "(nothing)", "a", "No personal a — non-specific person."),
("No veo _____ nadie.", "a", "(nothing)", "Personal a with nadie."),
("Llamo _____ mi madre.", "a", "(nothing)", "Personal a — specific person."),
("Extraño _____ mis amigos.", "a", "(nothing)", "Personal a — specific people."),
("Necesito _____ un traductor.", "(nothing)", "a", "No personal a — any translator."),
("Necesito _____ mi traductor.", "a", "(nothing)", "Personal a — specific person."),
("¿Conoces _____ alguien aquí?", "a", "(nothing)", "Personal a with alguien."),
("¿_____ quién llamaste?", "A", "(nothing)", "Personal a with quién."),
("Invité _____ Juan a la fiesta.", "a", "(nothing)", "Personal a — specific person."),
("Compré _____ un libro.", "(nothing)", "a", "No personal a — thing."),
("Llevo _____ mi perro al veterinario.", "a", "(nothing)", "Personal a — beloved pet."),
("Quiero _____ mi familia.", "a", "(nothing)", "Personal a — loving people."),
("Leo _____ un libro.", "(nothing)", "a", "No personal a — thing."),
("Escucho _____ mi profesora.", "a", "(nothing)", "Personal a — specific person."),
("Escucho _____ música.", "(nothing)", "a", "No personal a — thing."),
("Busco _____ mi hija.", "a", "(nothing)", "Personal a — specific person."),
("Busco _____ mis llaves.", "(nothing)", "a", "No personal a — things."),
("Vi _____ Carlos en el parque.", "a", "(nothing)", "Personal a — specific person."),
("Vi _____ una película.", "(nothing)", "a", "No personal a — thing."),
("Admiro _____ esa mujer.", "a", "(nothing)", "Personal a — specific person."),
("Tiene _____ tres hijos.", "(nothing)", "a", "No personal a after tener."),
("Ayudo _____ mi vecina.", "a", "(nothing)", "Personal a — specific person."),
("Encontré _____ Pedro en la tienda.", "a", "(nothing)", "Personal a — specific person."),
("Encontré _____ un buen restaurante.", "(nothing)", "a", "No personal a — thing/place."),
("Esperamos _____ nuestros padres.", "a", "(nothing)", "Personal a — specific people."),
("Esperamos _____ el autobús.", "(nothing)", "a", "No personal a — thing."),
("Odio _____ la violencia.", "(nothing)", "a", "No personal a — abstract concept."),
("Odio _____ ese hombre.", "a", "(nothing)", "Personal a — specific person."),
("Contrataron _____ un ingeniero.", "(nothing)", "a", "No personal a — non-specific person."),
("Contrataron _____ María.", "a", "(nothing)", "Personal a — specific person."),
("Cuido _____ mis hijos.", "a", "(nothing)", "Personal a — caring for people."),
("Cuido _____ mi jardín.", "(nothing)", "a", "No personal a — thing."),
("Respeto _____ mis abuelos.", "a", "(nothing)", "Personal a — specific people."),
("Respeto _____ las reglas.", "(nothing)", "a", "No personal a — things."),
("Visité _____ mi tía.", "a", "(nothing)", "Personal a — specific person."),
("Visité _____ el museo.", "(nothing)", "a", "No personal a — place."),
("Abandonó _____ su familia.", "a", "(nothing)", "Personal a — people."),
("Abandonó _____ su coche.", "(nothing)", "a", "No personal a — thing."),
("Presenté _____ mi novio.", "a", "(nothing)", "Personal a — specific person."),
("Traje _____ mi hermano.", "a", "(nothing)", "Personal a — specific person."),
("Traje _____ comida.", "(nothing)", "a", "No personal a — thing."),
("Echamos de menos _____ nuestros amigos.", "a", "(nothing)", "Personal a — missing people."),
("Mandé _____ los niños al colegio.", "a", "(nothing)", "Personal a — sending people."),
("Mandé _____ una carta.", "(nothing)", "a", "No personal a — thing."),
("Saludé _____ la vecina.", "a", "(nothing)", "Personal a — specific person."),
("Abrí _____ la puerta.", "(nothing)", "a", "No personal a — thing."),
("Elegimos _____ un nuevo líder.", "a", "(nothing)", "Personal a — specific person elected."),
("Elegimos _____ un buen restaurante.", "(nothing)", "a", "No personal a — thing."),
("Acusaron _____ el sospechoso.", "a", "(nothing)", "Personal a — specific person."),
("Derribaron _____ el edificio.", "(nothing)", "a", "No personal a — thing."),
("Recogí _____ los niños del colegio.", "a", "(nothing)", "Personal a — picking up people."),
("Recogí _____ mis cosas.", "(nothing)", "a", "No personal a — things."),
("Críticaron _____ el presidente.", "a", "(nothing)", "Personal a — specific person."),
("Critícaron _____ la decisión.", "(nothing)", "a", "No personal a — thing."),
("Perdoné _____ mi amigo.", "a", "(nothing)", "Personal a — specific person."),
("Perdoné _____ su error.", "(nothing)", "a", "No personal a — thing."),
("Describió _____ su madre.", "a", "(nothing)", "Personal a — specific person."),
("Describió _____ la situación.", "(nothing)", "a", "No personal a — thing."),
("Abracé _____ mi abuela.", "a", "(nothing)", "Personal a — specific person."),
("Obedezco _____ mis padres.", "a", "(nothing)", "Personal a — people."),
("Obedezco _____ las leyes.", "(nothing)", "a", "No personal a — things."),
("Felicité _____ mi compañero.", "a", "(nothing)", "Personal a — specific person."),
("Cuidamos _____ nuestro gato.", "a", "(nothing)", "Personal a — beloved pet."),
("Cuidamos _____ la casa.", "(nothing)", "a", "No personal a — thing."),
("Castigaron _____ los culpables.", "a", "(nothing)", "Personal a — specific people."),
("Repararon _____ el techo.", "(nothing)", "a", "No personal a — thing."),
("Defendí _____ mi hermana.", "a", "(nothing)", "Personal a — specific person."),
("Defendí _____ mi posición.", "(nothing)", "a", "No personal a — abstract."),
("Acompañé _____ mi amiga al aeropuerto.", "a", "(nothing)", "Personal a — specific person."),
("Ignoré _____ el comentario.", "(nothing)", "a", "No personal a — thing."),
("Ignoré _____ esa persona.", "a", "(nothing)", "Personal a — specific person."),
("Reconocí _____ Juan inmediatamente.", "a", "(nothing)", "Personal a — specific person."),
("Reconocí _____ la canción.", "(nothing)", "a", "No personal a — thing."),
("Salvaron _____ los pasajeros.", "a", "(nothing)", "Personal a — people."),
("Salvaron _____ los documentos.", "(nothing)", "a", "No personal a — things."),
("Atendemos _____ nuestros clientes.", "a", "(nothing)", "Personal a — people."),
("Atendemos _____ los pedidos.", "(nothing)", "a", "No personal a — things."),
("Despidieron _____ tres empleados.", "a", "(nothing)", "Personal a — people."),
("Pintaron _____ la casa.", "(nothing)", "a", "No personal a — thing."),
("Enseño _____ mis estudiantes.", "a", "(nothing)", "Personal a — people."),
("Enseño _____ español.", "(nothing)", "a", "No personal a — subject/thing."),
("Protegemos _____ los niños.", "a", "(nothing)", "Personal a — people."),
("Protegemos _____ el medio ambiente.", "(nothing)", "a", "No personal a — thing."),
("Entrevisté _____ la candidata.", "a", "(nothing)", "Personal a — specific person."),
("Preparé _____ la cena.", "(nothing)", "a", "No personal a — thing."),
("Culparon _____ los responsables.", "a", "(nothing)", "Personal a — people."),
("Cerraron _____ la tienda.", "(nothing)", "a", "No personal a — thing."),
("Seguí _____ el ladrón.", "a", "(nothing)", "Personal a — specific person."),
("Seguí _____ las instrucciones.", "(nothing)", "a", "No personal a — things."),
("Engañaron _____ los clientes.", "a", "(nothing)", "Personal a — people."),
("Rompieron _____ la ventana.", "(nothing)", "a", "No personal a — thing."),
("Consulté _____ un especialista.", "a", "(nothing)", "Personal a — specific person."),
("Consulté _____ un diccionario.", "(nothing)", "a", "No personal a — thing."),
("Persiguieron _____ los criminales.", "a", "(nothing)", "Personal a — people."),
("Lavé _____ el coche.", "(nothing)", "a", "No personal a — thing."),
("Detuvieron _____ los manifestantes.", "a", "(nothing)", "Personal a — people."),
]
return data.enumerated().map { i, d in
GrammarExercise(id: "pa\(i+1)", prompt: "Is the personal 'a' needed?", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
}
}()
}

View File

@@ -0,0 +1,78 @@
import Foundation
import FoundationModels
import SharedModels
@MainActor
@Observable
final class ConversationService {
var isResponding = false
private var session: LanguageModelSession?
static let scenarios = [
"Ordering at a restaurant",
"Asking for directions",
"Shopping at a market",
"Checking into a hotel",
"Making plans with a friend",
"At the doctor's office",
"Job interview",
"Renting an apartment",
"At the airport",
"Meeting someone new",
]
func startConversation(scenario: String, level: String) -> String {
session = LanguageModelSession(instructions: """
You are a friendly Spanish conversation partner. The scenario is: \(scenario).
The student's level is: \(level).
Rules:
- Respond ONLY in Spanish appropriate for the student's level.
- Keep responses to 1-3 sentences.
- If the student makes a grammar mistake, gently correct it in parentheses \
at the end of your response, like: (Pequeña corrección: "fuiste" en vez de "fue")
- Stay in character for the scenario.
- Be encouraging and natural.
""")
// Return the opening message based on scenario
switch scenario {
case "Ordering at a restaurant":
return "¡Bienvenido! Soy su mesero. ¿Ya sabe qué le gustaría ordenar?"
case "Asking for directions":
return "¡Hola! ¿En qué le puedo ayudar? ¿Está buscando algún lugar?"
case "Shopping at a market":
return "¡Buenos días! Tenemos frutas muy frescas hoy. ¿Qué le gustaría comprar?"
case "Checking into a hotel":
return "Buenas tardes, bienvenido al Hotel Sol. ¿Tiene una reservación?"
case "Making plans with a friend":
return "¡Oye! ¿Qué quieres hacer este fin de semana? Estoy libre el sábado."
case "At the doctor's office":
return "Buenos días. Soy el doctor García. ¿Cómo se siente hoy? ¿Qué le pasa?"
case "Job interview":
return "Buenos días, gracias por venir. Cuénteme un poco sobre usted."
case "Renting an apartment":
return "¡Hola! Gracias por su interés en el apartamento. ¿Qué preguntas tiene?"
case "At the airport":
return "Buenas tardes, pasajero. ¿Me puede mostrar su pasaporte y su boleto?"
case "Meeting someone new":
return "¡Hola! Me llamo Carlos. ¿Cómo te llamas? ¿De dónde eres?"
default:
return "¡Hola! ¿Cómo estás? Vamos a practicar español juntos."
}
}
func respond(to userMessage: String) async throws -> String {
guard let session else { return "Error: no session" }
isResponding = true
defer { isResponding = false }
let response = try await session.respond(to: userMessage)
return response.content
}
static var isAvailable: Bool {
SystemLanguageModel.default.availability == .available
}
}

View File

@@ -3,6 +3,21 @@ import SharedModels
import Foundation
actor DataLoader {
static let courseDataVersion = 6
static let courseDataKey = "courseDataVersion"
/// Quick check: does the DB need seeding or course data refresh?
static func needsSeeding(container: ModelContainer) async -> Bool {
let context = ModelContext(container)
let verbCount = (try? context.fetchCount(FetchDescriptor<Verb>())) ?? 0
if verbCount == 0 { return true }
let storedVersion = UserDefaults.standard.integer(forKey: courseDataKey)
if storedVersion < courseDataVersion { return true }
return false
}
static func seedIfNeeded(container: ModelContainer) async {
let context = ModelContext(container)
@@ -123,11 +138,9 @@ 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 = 5 // Bump this whenever course_data.json changes
let key = "courseDataVersion"
let shared = UserDefaults.standard
if shared.integer(forKey: key) >= currentVersion { return }
if shared.integer(forKey: courseDataKey) >= courseDataVersion { return }
print("Course data version outdated — re-seeding...")
let context = ModelContext(container)
@@ -140,8 +153,8 @@ actor DataLoader {
// Re-seed
seedCourseData(context: context)
shared.set(currentVersion, forKey: key)
print("Course data re-seeded to version \(currentVersion)")
shared.set(courseDataVersion, forKey: courseDataKey)
print("Course data re-seeded to version \(courseDataVersion)")
}
static func migrateCourseProgressIfNeeded(

View File

@@ -0,0 +1,268 @@
import Foundation
import SharedModels
import SwiftData
@MainActor
@Observable
final class DictionaryService {
struct Entry {
let word: String
let baseForm: String
let english: String
let partOfSpeech: String
let tenseId: String?
let person: String?
}
private var verbIndex: [String: Entry] = [:]
private var nonVerbIndex: [String: Entry] = [:]
private var isBuilt = false
/// Build the reverse index from existing verb data + bundled non-verb dictionary.
/// Loads from disk cache if available, otherwise builds from DB and caches.
func buildIfNeeded(context: ModelContext) {
guard !isBuilt else { return }
loadNonVerbDictionary()
if loadCachedIndex() {
isBuilt = true
return
}
// No cache build from DB
let verbDescriptor = FetchDescriptor<Verb>()
let verbs = (try? context.fetch(verbDescriptor)) ?? []
let verbMap = Dictionary(uniqueKeysWithValues: verbs.map { ($0.id, $0) })
let formDescriptor = FetchDescriptor<VerbForm>()
let forms = (try? context.fetch(formDescriptor)) ?? []
let persons = TenseInfo.persons
for form in forms {
guard let verb = verbMap[form.verbId] else { continue }
let key = form.form.lowercased()
if verbIndex[key] != nil { continue }
let person = form.personIndex < persons.count ? persons[form.personIndex] : nil
verbIndex[key] = Entry(
word: form.form,
baseForm: verb.infinitive,
english: verb.english,
partOfSpeech: "verb",
tenseId: form.tenseId,
person: person
)
}
for verb in verbs {
let key = verb.infinitive.lowercased()
if verbIndex[key] == nil {
verbIndex[key] = Entry(
word: verb.infinitive,
baseForm: verb.infinitive,
english: verb.english,
partOfSpeech: "verb",
tenseId: nil,
person: nil
)
}
}
isBuilt = true
saveCachedIndex()
print("[Dictionary] Built index from DB: \(verbIndex.count) verb forms")
}
// MARK: - Disk Cache
private static var cacheURL: URL {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("dictionary_index.json")
}
private struct CachedEntry: Codable {
let word: String
let baseForm: String
let english: String
let partOfSpeech: String
let tenseId: String?
let person: String?
}
private func saveCachedIndex() {
let entries = verbIndex.map { (key: $0.key, value: CachedEntry(
word: $0.value.word, baseForm: $0.value.baseForm,
english: $0.value.english, partOfSpeech: $0.value.partOfSpeech,
tenseId: $0.value.tenseId, person: $0.value.person
))}
let dict = Dictionary(uniqueKeysWithValues: entries)
if let data = try? JSONEncoder().encode(dict) {
try? data.write(to: Self.cacheURL)
}
}
private func loadCachedIndex() -> Bool {
guard let data = try? Data(contentsOf: Self.cacheURL),
let dict = try? JSONDecoder().decode([String: CachedEntry].self, from: data) else {
return false
}
verbIndex = dict.mapValues { Entry(
word: $0.word, baseForm: $0.baseForm,
english: $0.english, partOfSpeech: $0.partOfSpeech,
tenseId: $0.tenseId, person: $0.person
)}
print("[Dictionary] Loaded cached index: \(verbIndex.count) verb forms")
return true
}
func lookup(_ word: String) -> Entry? {
let cleaned = word.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
return verbIndex[cleaned] ?? nonVerbIndex[cleaned]
}
private func loadNonVerbDictionary() {
// Common non-verb Spanish words articles, prepositions, pronouns, adjectives, nouns, adverbs, conjunctions
let words: [(String, String, String)] = [
// Articles
("el", "the (masc.)", "article"), ("la", "the (fem.)", "article"),
("los", "the (masc. pl.)", "article"), ("las", "the (fem. pl.)", "article"),
("un", "a, an (masc.)", "article"), ("una", "a, an (fem.)", "article"),
("unos", "some (masc.)", "article"), ("unas", "some (fem.)", "article"),
// Pronouns
("yo", "I", "pronoun"), ("", "you (informal)", "pronoun"),
("él", "he", "pronoun"), ("ella", "she", "pronoun"),
("nosotros", "we (masc.)", "pronoun"), ("nosotras", "we (fem.)", "pronoun"),
("ellos", "they (masc.)", "pronoun"), ("ellas", "they (fem.)", "pronoun"),
("usted", "you (formal)", "pronoun"), ("ustedes", "you all (formal)", "pronoun"),
("me", "me", "pronoun"), ("te", "you (obj.)", "pronoun"),
("nos", "us", "pronoun"), ("le", "him/her/you (obj.)", "pronoun"),
("les", "them/you all (obj.)", "pronoun"), ("lo", "it/him (obj.)", "pronoun"),
("se", "self/each other", "pronoun"), ("mi", "my", "pronoun"),
("tu", "your (informal)", "pronoun"), ("su", "his/her/your/their", "pronoun"),
("nuestro", "our (masc.)", "pronoun"), ("nuestra", "our (fem.)", "pronoun"),
("esto", "this", "pronoun"), ("eso", "that", "pronoun"),
("algo", "something", "pronoun"), ("nada", "nothing", "pronoun"),
("alguien", "someone", "pronoun"), ("nadie", "nobody", "pronoun"),
("todo", "everything, all", "pronoun"), ("cada", "each", "pronoun"),
("otro", "other, another", "pronoun"), ("otra", "other, another (fem.)", "pronoun"),
("mismo", "same, self", "pronoun"), ("misma", "same, self (fem.)", "pronoun"),
// Prepositions
("a", "to, at", "preposition"), ("de", "of, from", "preposition"),
("en", "in, on, at", "preposition"), ("con", "with", "preposition"),
("por", "for, by, through", "preposition"), ("para", "for, in order to", "preposition"),
("sin", "without", "preposition"), ("sobre", "on, about", "preposition"),
("entre", "between, among", "preposition"), ("hasta", "until, up to", "preposition"),
("desde", "from, since", "preposition"), ("hacia", "toward", "preposition"),
("durante", "during", "preposition"), ("según", "according to", "preposition"),
("tras", "after, behind", "preposition"), ("contra", "against", "preposition"),
// Conjunctions
("y", "and", "conjunction"), ("e", "and (before i/hi)", "conjunction"),
("o", "or", "conjunction"), ("u", "or (before o/ho)", "conjunction"),
("pero", "but", "conjunction"), ("sino", "but rather", "conjunction"),
("porque", "because", "conjunction"), ("que", "that, which", "conjunction"),
("si", "if", "conjunction"), ("cuando", "when", "conjunction"),
("como", "as, like, how", "conjunction"), ("donde", "where", "conjunction"),
("aunque", "although", "conjunction"), ("mientras", "while", "conjunction"),
("ni", "neither, nor", "conjunction"), ("pues", "well, since", "conjunction"),
// Common adverbs
("no", "no, not", "adverb"), ("", "yes", "adverb"),
("muy", "very", "adverb"), ("más", "more, most", "adverb"),
("menos", "less, fewer", "adverb"), ("bien", "well", "adverb"),
("mal", "badly", "adverb"), ("ya", "already, now", "adverb"),
("también", "also, too", "adverb"), ("tampoco", "neither, either", "adverb"),
("aquí", "here", "adverb"), ("ahí", "there", "adverb"),
("allí", "over there", "adverb"), ("siempre", "always", "adverb"),
("nunca", "never", "adverb"), ("hoy", "today", "adverb"),
("ayer", "yesterday", "adverb"), ("mañana", "tomorrow", "adverb"),
("ahora", "now", "adverb"), ("después", "after, later", "adverb"),
("antes", "before", "adverb"), ("luego", "then, later", "adverb"),
("todavía", "still, yet", "adverb"), ("casi", "almost", "adverb"),
("solo", "only, alone", "adverb"), ("tan", "so, as", "adverb"),
("mucho", "a lot, much", "adverb"), ("poco", "little, few", "adverb"),
("bastante", "quite, enough", "adverb"), ("demasiado", "too much", "adverb"),
// Question words
("qué", "what", "interrogative"), ("quién", "who", "interrogative"),
("cómo", "how", "interrogative"), ("dónde", "where", "interrogative"),
("cuándo", "when", "interrogative"), ("cuánto", "how much", "interrogative"),
("cuál", "which", "interrogative"), ("por qué", "why", "interrogative"),
// Common nouns
("casa", "house", "noun"), ("hombre", "man", "noun"),
("mujer", "woman", "noun"), ("niño", "boy, child", "noun"),
("niña", "girl", "noun"), ("familia", "family", "noun"),
("amigo", "friend (masc.)", "noun"), ("amiga", "friend (fem.)", "noun"),
("tiempo", "time, weather", "noun"), ("día", "day", "noun"),
("noche", "night", "noun"), ("año", "year", "noun"),
("vida", "life", "noun"), ("mundo", "world", "noun"),
("país", "country", "noun"), ("ciudad", "city", "noun"),
("agua", "water", "noun"), ("comida", "food", "noun"),
("trabajo", "work, job", "noun"), ("escuela", "school", "noun"),
("libro", "book", "noun"), ("calle", "street", "noun"),
("dinero", "money", "noun"), ("mano", "hand", "noun"),
("padre", "father", "noun"), ("madre", "mother", "noun"),
("hijo", "son", "noun"), ("hija", "daughter", "noun"),
("hermano", "brother", "noun"), ("hermana", "sister", "noun"),
("persona", "person", "noun"), ("gente", "people", "noun"),
("cosa", "thing", "noun"), ("lugar", "place", "noun"),
("parte", "part", "noun"), ("nombre", "name", "noun"),
("momento", "moment", "noun"), ("problema", "problem", "noun"),
("mesa", "table", "noun"), ("puerta", "door", "noun"),
("coche", "car", "noun"), ("perro", "dog", "noun"),
("gato", "cat", "noun"), ("sol", "sun", "noun"),
("mar", "sea", "noun"), ("playa", "beach", "noun"),
("montaña", "mountain", "noun"), ("tienda", "store", "noun"),
("restaurante", "restaurant", "noun"), ("hotel", "hotel", "noun"),
("cuerpo", "body", "noun"), ("cabeza", "head", "noun"),
("corazón", "heart", "noun"), ("ojo", "eye", "noun"),
// Common adjectives
("bueno", "good", "adjective"), ("buena", "good (fem.)", "adjective"),
("malo", "bad", "adjective"), ("mala", "bad (fem.)", "adjective"),
("grande", "big, great", "adjective"), ("pequeño", "small", "adjective"),
("nuevo", "new", "adjective"), ("viejo", "old", "adjective"),
("joven", "young", "adjective"), ("largo", "long", "adjective"),
("corto", "short", "adjective"), ("alto", "tall, high", "adjective"),
("bajo", "short, low", "adjective"), ("bonito", "pretty", "adjective"),
("hermoso", "beautiful", "adjective"), ("feo", "ugly", "adjective"),
("feliz", "happy", "adjective"), ("triste", "sad", "adjective"),
("fácil", "easy", "adjective"), ("difícil", "difficult", "adjective"),
("importante", "important", "adjective"), ("posible", "possible", "adjective"),
("mejor", "better, best", "adjective"), ("peor", "worse, worst", "adjective"),
("primero", "first", "adjective"), ("último", "last", "adjective"),
("mismo", "same", "adjective"), ("otro", "other", "adjective"),
("cada", "each, every", "adjective"), ("todo", "all, every", "adjective"),
("mucho", "much, many", "adjective"), ("poco", "little, few", "adjective"),
// Numbers
("uno", "one", "number"), ("dos", "two", "number"),
("tres", "three", "number"), ("cuatro", "four", "number"),
("cinco", "five", "number"), ("seis", "six", "number"),
("siete", "seven", "number"), ("ocho", "eight", "number"),
("nueve", "nine", "number"), ("diez", "ten", "number"),
// Misc
("del", "of the (de + el)", "contraction"), ("al", "to the (a + el)", "contraction"),
]
for (word, english, pos) in words {
nonVerbIndex[word.lowercased()] = Entry(
word: word,
baseForm: word,
english: english,
partOfSpeech: pos,
tenseId: nil,
person: nil
)
}
}
}

View File

@@ -0,0 +1,155 @@
import Foundation
import Speech
import AVFoundation
@MainActor
@Observable
final class PronunciationService {
var isRecording = false
var transcript = ""
var isAuthorized = false
private var recognizer: SFSpeechRecognizer?
private var audioEngine: AVAudioEngine?
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
private var recognizerResolved = false
func requestAuthorization() {
#if targetEnvironment(simulator)
print("[PronunciationService] skipping speech auth on simulator")
return
#else
// Check current status first to avoid unnecessary prompt
let currentStatus = SFSpeechRecognizer.authorizationStatus()
if currentStatus == .authorized {
isAuthorized = true
return
}
if currentStatus == .denied || currentStatus == .restricted {
isAuthorized = false
return
}
// Only request if not determined yet do it on a background queue
// to avoid blocking main thread, then update state on main
DispatchQueue.global(qos: .userInitiated).async {
SFSpeechRecognizer.requestAuthorization { status in
DispatchQueue.main.async { [weak self] in
self?.isAuthorized = (status == .authorized)
print("[PronunciationService] authorization status: \(status.rawValue)")
}
}
}
#endif
}
private func resolveRecognizerIfNeeded() {
guard !recognizerResolved else { return }
recognizerResolved = true
recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))
}
func startRecording() {
guard isAuthorized else {
print("[PronunciationService] not authorized")
return
}
resolveRecognizerIfNeeded()
guard let recognizer, recognizer.isAvailable else {
print("[PronunciationService] recognizer unavailable")
return
}
stopRecording()
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
audioEngine = AVAudioEngine()
request = SFSpeechAudioBufferRecognitionRequest()
guard let audioEngine, let request else { return }
request.shouldReportPartialResults = true
let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
// Validate format 0 channels crashes installTap
guard recordingFormat.channelCount > 0 else {
print("[PronunciationService] invalid recording format (0 channels)")
self.audioEngine = nil
self.request = nil
return
}
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
guard buffer.frameLength > 0 else { return }
request.append(buffer)
}
audioEngine.prepare()
try audioEngine.start()
transcript = ""
isRecording = true
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
DispatchQueue.main.async {
if let result {
self?.transcript = result.bestTranscription.formattedString
}
if error != nil || (result?.isFinal == true) {
self?.stopRecording()
}
}
}
} catch {
print("[PronunciationService] startRecording failed: \(error)")
stopRecording()
}
}
func stopRecording() {
audioEngine?.stop()
audioEngine?.inputNode.removeTap(onBus: 0)
request?.endAudio()
task?.cancel()
task = nil
request = nil
audioEngine = nil
isRecording = false
}
/// Compare spoken transcript against expected text, returns matched word ratio (0.0-1.0).
static func scoreMatch(expected: String, spoken: String) -> (score: Double, matches: [WordMatch]) {
let expectedWords = expected.lowercased()
.components(separatedBy: .whitespacesAndNewlines)
.map { $0.trimmingCharacters(in: .punctuationCharacters) }
.filter { !$0.isEmpty }
let spokenWords = spoken.lowercased()
.components(separatedBy: .whitespacesAndNewlines)
.map { $0.trimmingCharacters(in: .punctuationCharacters) }
.filter { !$0.isEmpty }
let spokenSet = Set(spokenWords)
var matches: [WordMatch] = []
for word in expectedWords {
matches.append(WordMatch(word: word, matched: spokenSet.contains(word)))
}
let matchCount = matches.filter(\.matched).count
let score = expectedWords.isEmpty ? 0 : Double(matchCount) / Double(expectedWords.count)
return (score, matches)
}
struct WordMatch: Identifiable {
let word: String
let matched: Bool
var id: String { word }
}
}

View File

@@ -20,10 +20,16 @@ struct DashboardView: View {
// Summary stats
statsGrid
// Study time + Activity side by side
HStack(alignment: .top, spacing: 12) {
studyTimeCard
streakCalendar
// Study time + Activity side by side on iPad, stacked on iPhone
ViewThatFits(in: .horizontal) {
HStack(alignment: .top, spacing: 12) {
studyTimeCard
streakCalendar
}
VStack(spacing: 12) {
studyTimeCard
streakCalendar
}
}
// Accuracy chart

View File

@@ -0,0 +1,164 @@
import SwiftUI
struct GrammarExerciseView: View {
let noteId: String
let noteTitle: String
@Environment(\.dismiss) private var dismiss
@State private var exercises: [GrammarExercise] = []
@State private var currentIndex = 0
@State private var selectedOption: Int?
@State private var correctCount = 0
@State private var isFinished = false
var body: some View {
VStack(spacing: 20) {
if isFinished {
finishedView
} else if let ex = exercises[safe: currentIndex] {
exerciseView(ex)
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Practice: \(noteTitle)")
.navigationBarTitleDisplayMode(.inline)
.onAppear { exercises = Array(GrammarExercise.exercises(for: noteId).shuffled().prefix(10)) }
}
@ViewBuilder
private func exerciseView(_ ex: GrammarExercise) -> some View {
VStack(spacing: 20) {
Text("\(currentIndex + 1) / \(exercises.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
ProgressView(value: Double(currentIndex), total: Double(exercises.count))
.tint(.purple)
// Prompt
Text(ex.prompt)
.font(.subheadline)
.foregroundStyle(.secondary)
// Sentence
Text(highlightBlank(ex.sentence))
.font(.title3)
.multilineTextAlignment(.center)
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
// Options
VStack(spacing: 10) {
ForEach(Array(ex.options.enumerated()), id: \.offset) { index, option in
Button {
guard selectedOption == nil else { return }
selectedOption = index
if option == ex.correctAnswer { correctCount += 1 }
} label: {
HStack {
Text(option)
.font(.body.weight(.medium))
Spacer()
if let selected = selectedOption {
if option == ex.correctAnswer {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
} else if index == selected {
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(optionBG(index: index, correct: ex.correctAnswer, options: ex.options), in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
// Explanation after answer
if selectedOption != nil {
Text(ex.explanation)
.font(.callout)
.foregroundStyle(.secondary)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
.transition(.opacity)
}
Spacer()
if selectedOption != nil {
Button {
if currentIndex + 1 < exercises.count {
currentIndex += 1
selectedOption = nil
} else {
withAnimation { isFinished = true }
}
} label: {
Text(currentIndex + 1 < exercises.count ? "Next" : "See Results")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
}
}
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: correctCount == exercises.count ? "star.fill" : "checkmark.circle")
.font(.system(size: 60))
.foregroundStyle(correctCount == exercises.count ? .yellow : .purple)
Text("\(correctCount) / \(exercises.count)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text(correctCount == exercises.count ? "Perfect!" : "Keep reviewing this topic.")
.font(.title3)
.foregroundStyle(.secondary)
Button {
dismiss()
} label: {
Text("Done")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
.padding(.horizontal)
Spacer()
}
}
private func highlightBlank(_ text: String) -> AttributedString {
var result = AttributedString(text)
if let range = result.range(of: "_____") {
result[range].foregroundColor = .purple
result[range].font = .title3.bold()
}
return result
}
private func optionBG(index: Int, correct: String, options: [String]) -> some ShapeStyle {
guard let selected = selectedOption else { return AnyShapeStyle(.fill.quaternary) }
if options[index] == correct { return AnyShapeStyle(.green.opacity(0.15)) }
if index == selected { return AnyShapeStyle(.red.opacity(0.15)) }
return AnyShapeStyle(.fill.quaternary)
}
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@@ -87,6 +87,20 @@ struct GrammarNoteDetailView: View {
// Parsed body
FormattedGrammarBody(content: note.body)
// Practice button (if exercises exist for this note)
if !GrammarExercise.exercises(for: note.id).isEmpty {
NavigationLink {
GrammarExerciseView(noteId: note.id, noteTitle: note.title)
} label: {
Label("Practice This", systemImage: "pencil.and.list.clipboard")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
}
.padding()
.adaptiveContainer(maxWidth: 800)

View File

@@ -0,0 +1,126 @@
import SwiftUI
import SharedModels
import SwiftData
struct ChatLibraryView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var conversations: [Conversation] = []
@State private var showingScenarioPicker = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
Group {
if conversations.isEmpty {
ContentUnavailableView(
"No Conversations Yet",
systemImage: "bubble.left.and.bubble.right",
description: Text("Tap + to start a Spanish conversation.")
)
} else {
List {
ForEach(conversations) { conv in
NavigationLink {
ChatView(conversation: conv)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(conv.scenario)
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Text(conv.level.capitalized)
.font(.caption2.weight(.medium))
.foregroundStyle(.green)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.green.opacity(0.12), in: Capsule())
let msgCount = conv.decodedMessages.count
Text("\(msgCount) message\(msgCount == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
.onDelete(perform: deleteConversations)
}
}
}
.navigationTitle("Conversations")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingScenarioPicker = true
} label: {
Image(systemName: "plus")
}
.disabled(!ConversationService.isAvailable)
}
}
.sheet(isPresented: $showingScenarioPicker) {
ScenarioPickerView { scenario in
showingScenarioPicker = false
createConversation(scenario: scenario)
}
}
.onAppear(perform: loadConversations)
}
private func loadConversations() {
let descriptor = FetchDescriptor<Conversation>(
sortBy: [SortDescriptor(\Conversation.createdDate, order: .reverse)]
)
conversations = (try? cloudContext.fetch(descriptor)) ?? []
}
private func createConversation(scenario: String) {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let conv = Conversation(scenario: scenario, level: progress.selectedLevel)
cloudContext.insert(conv)
try? cloudContext.save()
loadConversations()
}
private func deleteConversations(at offsets: IndexSet) {
for index in offsets { cloudContext.delete(conversations[index]) }
try? cloudContext.save()
loadConversations()
}
}
// MARK: - Scenario Picker
struct ScenarioPickerView: View {
let onPick: (String) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(ConversationService.scenarios, id: \.self) { scenario in
Button {
onPick(scenario)
} label: {
HStack {
Text(scenario)
.font(.body)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.tint(.primary)
}
}
.navigationTitle("Choose a Scenario")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}

View File

@@ -0,0 +1,150 @@
import SwiftUI
import SharedModels
import SwiftData
struct ChatView: View {
let conversation: Conversation
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var service = ConversationService()
@State private var messages: [ChatMessage] = []
@State private var inputText = ""
@State private var errorMessage: String?
@State private var hasStarted = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
VStack(spacing: 0) {
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(messages) { message in
ChatBubble(message: message)
.id(message.id)
}
if service.isResponding {
HStack {
ProgressView()
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 16))
Spacer()
}
.padding(.horizontal)
}
}
.padding(.vertical)
}
.onChange(of: messages.count) {
if let last = messages.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
Divider()
// Input
HStack(spacing: 8) {
TextField("Type in Spanish...", text: $inputText)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.sentences)
.onSubmit { sendMessage() }
Button {
sendMessage()
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.disabled(inputText.trimmingCharacters(in: .whitespaces).isEmpty || service.isResponding)
.tint(.green)
}
.padding()
}
.navigationTitle(conversation.scenario)
.navigationBarTitleDisplayMode(.inline)
.alert("Error", isPresented: .init(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
Button("OK") { errorMessage = nil }
} message: {
Text(errorMessage ?? "")
}
.onAppear {
messages = conversation.decodedMessages
if !hasStarted && messages.isEmpty {
startConversation()
}
hasStarted = true
}
}
private func startConversation() {
let opening = service.startConversation(scenario: conversation.scenario, level: conversation.level)
let msg = ChatMessage(role: "assistant", content: opening)
conversation.appendMessage(msg)
messages = conversation.decodedMessages
try? cloudContext.save()
}
private func sendMessage() {
let text = inputText.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { return }
let userMsg = ChatMessage(role: "user", content: text)
conversation.appendMessage(userMsg)
messages = conversation.decodedMessages
inputText = ""
try? cloudContext.save()
Task {
do {
let response = try await service.respond(to: text)
let assistantMsg = ChatMessage(role: "assistant", content: response)
conversation.appendMessage(assistantMsg)
messages = conversation.decodedMessages
try? cloudContext.save()
} catch {
errorMessage = "Failed to get response: \(error.localizedDescription)"
}
}
}
}
// MARK: - Chat Bubble
private struct ChatBubble: View {
let message: ChatMessage
private var isUser: Bool { message.role == "user" }
var body: some View {
HStack {
if isUser { Spacer(minLength: 60) }
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
Text(message.content)
.font(.body)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(isUser ? AnyShapeStyle(.green.opacity(0.2)) : AnyShapeStyle(.fill.quaternary), in: RoundedRectangle(cornerRadius: 16))
if let correction = message.correction, !correction.isEmpty {
Text(correction)
.font(.caption)
.foregroundStyle(.orange)
.padding(.horizontal, 4)
}
}
if !isUser { Spacer(minLength: 60) }
}
.padding(.horizontal)
}
}

View File

@@ -0,0 +1,212 @@
import SwiftUI
import SharedModels
import SwiftData
struct ClozeView: View {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var questions: [ClozeQuestion] = []
@State private var currentIndex = 0
@State private var selectedOption: Int?
@State private var correctCount = 0
@State private var isFinished = false
@State private var isLoading = true
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
VStack(spacing: 20) {
if isLoading {
ProgressView("Loading questions...")
} else if isFinished || questions.isEmpty {
finishedView
} else if let q = questions[safe: currentIndex] {
questionView(q)
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Cloze Practice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadQuestions)
}
// MARK: - Question View
@ViewBuilder
private func questionView(_ q: ClozeQuestion) -> some View {
VStack(spacing: 20) {
Text("\(currentIndex + 1) / \(questions.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
ProgressView(value: Double(currentIndex), total: Double(questions.count))
.tint(.indigo)
// Sentence with blank
Text(highlightedTemplate(q.displayTemplate))
.font(.title3)
.multilineTextAlignment(.center)
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
// English hint
if !q.sentenceEN.isEmpty {
Text(q.sentenceEN)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
// Options
VStack(spacing: 10) {
ForEach(Array(q.options.enumerated()), id: \.offset) { index, option in
Button {
guard selectedOption == nil else { return }
selectedOption = index
if option.lowercased() == q.answer.lowercased() {
correctCount += 1
}
} label: {
HStack {
Text(option)
.font(.body)
Spacer()
if let selected = selectedOption {
if option.lowercased() == q.answer.lowercased() {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
} else if index == selected {
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(optionBG(index: index, answer: q.answer, options: q.options), in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
Spacer()
if selectedOption != nil {
Button {
if currentIndex + 1 < questions.count {
currentIndex += 1
selectedOption = nil
} else {
withAnimation { isFinished = true }
}
} label: {
Text(currentIndex + 1 < questions.count ? "Next" : "See Results")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.indigo)
}
}
}
// MARK: - Finished
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: questions.isEmpty ? "text.badge.xmark" : "checkmark.circle")
.font(.system(size: 60))
.foregroundStyle(questions.isEmpty ? Color.secondary : Color.indigo)
if questions.isEmpty {
Text("No cloze questions available")
.font(.title3)
.foregroundStyle(.secondary)
Text("Complete some course decks first to unlock cloze practice.")
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
} else {
Text("\(correctCount) / \(questions.count)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text(correctCount == questions.count ? "Perfect!" : "Keep practicing!")
.font(.title3)
.foregroundStyle(.secondary)
}
Spacer()
}
}
// MARK: - Helpers
private func highlightedTemplate(_ template: String) -> AttributedString {
var result = AttributedString(template)
if let range = result.range(of: SentenceQuizEngine.blankMarker) {
result[range].foregroundColor = .indigo
result[range].font = .title3.bold()
}
return result
}
private func optionBG(index: Int, answer: String, options: [String]) -> some ShapeStyle {
guard let selected = selectedOption else { return AnyShapeStyle(.fill.quaternary) }
if options[index].lowercased() == answer.lowercased() { return AnyShapeStyle(.green.opacity(0.15)) }
if index == selected { return AnyShapeStyle(.red.opacity(0.15)) }
return AnyShapeStyle(.fill.quaternary)
}
private func loadQuestions() {
let descriptor = FetchDescriptor<VocabCard>()
let allCards = (try? localContext.fetch(descriptor)) ?? []
let eligible = allCards.filter { SentenceQuizEngine.hasValidSentence(for: $0) }
var result: [ClozeQuestion] = []
let pool = eligible.shuffled().prefix(20)
for card in pool {
guard let q = SentenceQuizEngine.buildQuestion(for: card) else { continue }
// Build distractors from other cards
var distractors = eligible
.filter { $0.front != card.front }
.shuffled()
.prefix(3)
.map(\.front)
while distractors.count < 3 {
distractors.append("---")
}
var options = Array(distractors) + [q.blankWord]
options.shuffle()
result.append(ClozeQuestion(
displayTemplate: q.displayTemplate,
sentenceEN: q.sentenceEN,
answer: q.blankWord,
options: options
))
if result.count >= 10 { break }
}
questions = result
isLoading = false
}
}
private struct ClozeQuestion {
let displayTemplate: String
let sentenceEN: String
let answer: String
let options: [String]
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@@ -0,0 +1,319 @@
import SwiftUI
import SharedModels
import SwiftData
struct ListeningView: View {
@Environment(\.modelContext) private var localContext
@State private var pronunciation = PronunciationService()
@State private var speechService = SpeechService()
@State private var sentences: [(spanish: String, english: String)] = []
@State private var currentIndex = 0
@State private var userInput = ""
@State private var isRevealed = false
@State private var score: Double?
@State private var wordMatches: [PronunciationService.WordMatch] = []
@State private var mode: ListeningMode = .listenType
@State private var correctCount = 0
@State private var isFinished = false
enum ListeningMode: String, CaseIterable {
case listenType = "Listen & Type"
case speakCheck = "Pronunciation"
}
var body: some View {
VStack(spacing: 20) {
if isFinished {
finishedView
} else if sentences.isEmpty {
ContentUnavailableView("No sentences available", systemImage: "waveform", description: Text("Complete some course decks first."))
} else {
exerciseView
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Listening Practice")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
print("[ListeningView] onAppear — loading sentences")
loadSentences()
print("[ListeningView] loaded \(sentences.count) sentences, requesting auth")
Task {
pronunciation.requestAuthorization()
}
}
}
// MARK: - Exercise
@ViewBuilder
private var exerciseView: some View {
VStack(spacing: 20) {
// Mode picker
Picker("Mode", selection: $mode) {
ForEach(ListeningMode.allCases, id: \.self) { m in
Text(m.rawValue).tag(m)
}
}
.pickerStyle(.segmented)
Text("\(currentIndex + 1) / \(sentences.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
if mode == .listenType {
listenAndTypeView
} else {
pronunciationCheckView
}
}
}
// MARK: - Listen & Type
@ViewBuilder
private var listenAndTypeView: some View {
let sentence = sentences[currentIndex]
VStack(spacing: 16) {
// Play button
Button {
speechService.speak(sentence.spanish)
} label: {
Label("Play", systemImage: "speaker.wave.2.fill")
.font(.headline)
.padding()
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.blue)
// User types what they heard
TextField("Type what you hear...", text: $userInput)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if isRevealed {
// Show correct answer
VStack(alignment: .leading, spacing: 8) {
Text("Correct:")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(sentence.spanish)
.font(.body.weight(.medium))
Text(sentence.english)
.font(.callout)
.foregroundStyle(.secondary)
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
Text("Score: \(Int(result.score * 100))%")
.font(.headline)
.foregroundStyle(result.score >= 0.8 ? .green : result.score >= 0.5 ? .orange : .red)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
nextButton
} else {
Button {
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
if result.score >= 0.7 { correctCount += 1 }
withAnimation { isRevealed = true }
} label: {
Text("Check")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
.disabled(userInput.isEmpty)
}
}
}
// MARK: - Pronunciation Check
@ViewBuilder
private var pronunciationCheckView: some View {
let sentence = sentences[currentIndex]
VStack(spacing: 16) {
// Show the sentence to read
Text(sentence.spanish)
.font(.title3.weight(.medium))
.multilineTextAlignment(.center)
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
Text(sentence.english)
.font(.callout)
.foregroundStyle(.secondary)
// Mic button
Button {
if pronunciation.isRecording {
pronunciation.stopRecording()
// Score after stopping
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: pronunciation.transcript)
score = result.score
wordMatches = result.matches
if result.score >= 0.7 { correctCount += 1 }
withAnimation { isRevealed = true }
} else {
pronunciation.startRecording()
}
} label: {
Label(pronunciation.isRecording ? "Stop" : "Start Speaking", systemImage: pronunciation.isRecording ? "stop.circle.fill" : "mic.circle.fill")
.font(.headline)
.padding()
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(pronunciation.isRecording ? .red : .green)
.disabled(!pronunciation.isAuthorized)
if !pronunciation.isAuthorized {
Text("Microphone access required. Enable in Settings.")
.font(.caption)
.foregroundStyle(.secondary)
}
if pronunciation.isRecording {
Text(pronunciation.transcript.isEmpty ? "Listening..." : pronunciation.transcript)
.font(.body)
.foregroundStyle(.secondary)
.italic()
}
if isRevealed, let score {
VStack(spacing: 8) {
Text("\(Int(score * 100))% match")
.font(.title2.bold())
.foregroundStyle(score >= 0.8 ? .green : score >= 0.5 ? .orange : .red)
// Word-by-word feedback
FlowLayout(spacing: 4) {
ForEach(wordMatches) { match in
Text(match.word)
.font(.body)
.foregroundStyle(match.matched ? .green : .red)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(match.matched ? .green.opacity(0.1) : .red.opacity(0.1), in: Capsule())
}
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
nextButton
}
}
}
// MARK: - Shared
private var nextButton: some View {
Button {
if currentIndex + 1 < sentences.count {
currentIndex += 1
userInput = ""
isRevealed = false
score = nil
wordMatches = []
} else {
withAnimation { isFinished = true }
}
} label: {
Text(currentIndex + 1 < sentences.count ? "Next" : "See Results")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "ear.fill")
.font(.system(size: 60))
.foregroundStyle(.blue)
Text("\(correctCount) / \(sentences.count)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text("Listening complete!")
.font(.title3)
.foregroundStyle(.secondary)
Spacer()
}
}
private func loadSentences() {
print("[ListeningView] fetching VocabCards from localContext...")
print("[ListeningView] context: \(localContext)")
let descriptor = FetchDescriptor<VocabCard>()
let cards: [VocabCard]
do {
let count = try localContext.fetchCount(descriptor)
print("[ListeningView] fetchCount = \(count)")
cards = try localContext.fetch(descriptor)
print("[ListeningView] fetched \(cards.count) VocabCards")
} catch {
print("[ListeningView] ERROR fetching VocabCards: \(error)")
return
}
var results: [(String, String)] = []
for card in cards.shuffled() {
for i in card.examplesES.indices {
let es = card.examplesES[i]
let en = i < card.examplesEN.count ? card.examplesEN[i] : ""
if es.split(separator: " ").count >= 4 {
results.append((es, en))
}
if results.count >= 10 { break }
}
if results.count >= 10 { break }
}
sentences = results
print("[ListeningView] selected \(sentences.count) sentences")
}
}
// Reuse FlowLayout from StoryReaderView import not needed since it's in the same module
// but we need a local copy since it's private there
private struct FlowLayout: Layout {
var spacing: CGFloat = 0
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
var height: CGFloat = 0
for row in rows { height += row.map { $0.height }.max() ?? 0 }
height += CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = computeRows(proposal: proposal, subviews: subviews)
var y = bounds.minY; var idx = 0
for row in rows {
var x = bounds.minX; let rh = row.map { $0.height }.max() ?? 0
for size in row { subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)); x += size.width; idx += 1 }
y += rh + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
let mw = proposal.width ?? .infinity; var rows: [[CGSize]] = [[]]; var cw: CGFloat = 0
for sv in subviews {
let s = sv.sizeThatFits(.unspecified)
if cw + s.width > mw && !rows[rows.count - 1].isEmpty { rows.append([]); cw = 0 }
rows[rows.count - 1].append(s); cw += s.width
}
return rows
}
}

View File

@@ -129,6 +129,99 @@ struct PracticeView: View {
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Conversation Practice
NavigationLink {
ChatLibraryView()
} label: {
HStack(spacing: 14) {
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Conversation")
.font(.subheadline.weight(.semibold))
Text("Chat with AI in Spanish scenarios")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Listening Practice
NavigationLink {
ListeningView()
} label: {
HStack(spacing: 14) {
Image(systemName: "ear.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("Listening")
.font(.subheadline.weight(.semibold))
Text("Listen and type, or practice pronunciation")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Cloze Practice
NavigationLink {
ClozeView()
} label: {
HStack(spacing: 14) {
Image(systemName: "text.badge.minus")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.indigo)
VStack(alignment: .leading, spacing: 2) {
Text("Cloze Practice")
.font(.subheadline.weight(.semibold))
Text("Fill in the missing word in context")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Stories
NavigationLink {
StoryLibraryView()
@@ -166,6 +259,46 @@ struct PracticeView: View {
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
// Vocab review
NavigationLink {
VocabReviewView()
} label: {
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.teal)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Vocab Review")
.font(.subheadline.weight(.semibold))
Text("Review due vocabulary cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
if dueCount > 0 {
Text("\(dueCount)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.teal, in: Capsule())
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Common tenses focus
Button {
viewModel.practiceMode = .flashcard

View File

@@ -5,6 +5,7 @@ import FoundationModels
struct StoryReaderView: View {
let story: Story
@Environment(DictionaryService.self) private var dictionary
@State private var selectedWord: WordAnnotation?
@State private var showTranslation = false
@State private var lookupCache: [String: WordAnnotation] = [:]
@@ -105,7 +106,20 @@ struct StoryReaderView: View {
}
private func lookupWord(_ word: String, inContext sentence: String) {
// Show immediately with loading state
// Try offline dictionary first
if let entry = dictionary.lookup(word) {
let annotation = WordAnnotation(
word: word,
baseForm: entry.baseForm,
english: entry.english,
partOfSpeech: entry.partOfSpeech
)
lookupCache[word] = annotation
selectedWord = annotation
return
}
// Fall back to on-device AI lookup
selectedWord = WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: "")
Task {

View File

@@ -0,0 +1,180 @@
import SwiftUI
import SharedModels
import SwiftData
struct VocabReviewView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.modelContext) private var localContext
@Environment(\.dismiss) private var dismiss
@State private var dueCards: [CourseReviewCard] = []
@State private var currentIndex = 0
@State private var isRevealed = false
@State private var sessionCorrect = 0
@State private var sessionTotal = 0
@State private var isFinished = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
VStack(spacing: 20) {
if isFinished || dueCards.isEmpty {
finishedView
} else if let card = dueCards[safe: currentIndex] {
cardView(card)
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Vocab Review")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadDueCards)
}
// MARK: - Card View
@ViewBuilder
private func cardView(_ card: CourseReviewCard) -> some View {
VStack(spacing: 24) {
// Progress
Text("\(currentIndex + 1) of \(dueCards.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
.tint(.teal)
Spacer()
// Front (Spanish)
Text(card.front)
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
if isRevealed {
// Back (English)
Text(card.back)
.font(.title2)
.foregroundStyle(.secondary)
.transition(.opacity.combined(with: .move(edge: .bottom)))
Spacer()
// Rating buttons
HStack(spacing: 12) {
ratingButton("Again", color: .red, quality: .again)
ratingButton("Hard", color: .orange, quality: .hard)
ratingButton("Good", color: .green, quality: .good)
ratingButton("Easy", color: .blue, quality: .easy)
}
} else {
Spacer()
Button {
withAnimation { isRevealed = true }
} label: {
Text("Show Answer")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.teal)
}
}
}
// MARK: - Finished View
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
.font(.system(size: 60))
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
if dueCards.isEmpty {
Text("All caught up!")
.font(.title2.bold())
Text("No vocabulary cards are due for review.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("\(sessionCorrect) / \(sessionTotal)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text("Review complete!")
.font(.title3)
.foregroundStyle(.secondary)
}
Spacer()
}
}
// MARK: - Helpers
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
Button {
rate(quality: quality)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.bordered)
.tint(color)
}
private func rate(quality: ReviewQuality) {
guard let card = dueCards[safe: currentIndex] else { return }
let store = CourseReviewStore(context: cloudContext)
let result = SRSEngine.review(
quality: quality,
currentEase: card.easeFactor,
currentInterval: card.interval,
currentReps: card.repetitions
)
card.easeFactor = result.easeFactor
card.interval = result.interval
card.repetitions = result.repetitions
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
card.lastReviewDate = Date()
try? cloudContext.save()
sessionTotal += 1
if quality != .again { sessionCorrect += 1 }
isRevealed = false
if currentIndex + 1 < dueCards.count {
currentIndex += 1
} else {
withAnimation { isFinished = true }
}
}
private func loadDueCards() {
let now = Date()
let descriptor = FetchDescriptor<CourseReviewCard>(
predicate: #Predicate<CourseReviewCard> { $0.dueDate <= now },
sortBy: [SortDescriptor(\CourseReviewCard.dueDate)]
)
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
}
static func dueCount(context: ModelContext) -> Int {
let now = Date()
let descriptor = FetchDescriptor<CourseReviewCard>(
predicate: #Predicate<CourseReviewCard> { $0.dueDate <= now }
)
return (try? context.fetchCount(descriptor)) ?? 0
}
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@@ -0,0 +1,252 @@
import SwiftUI
struct FeatureReferenceView: View {
var body: some View {
List {
Section("Verb Conjugation Practice") {
featureRow(
icon: "rectangle.stack", color: .blue,
title: "Flashcard / Typing / MC / Handwriting / Sentence Builder",
details: [
"Pulls from verb conjugation database (1,750 verbs)",
"Filtered by your Level setting",
"Filtered by your Enabled Tenses",
"Respects Include Vosotros setting",
"Due cards (SRS) shown first, then random",
]
)
featureRow(
icon: "tablecells", color: .blue,
title: "Full Table",
details: [
"Shows all 6 person forms for one verb + tense",
"Random verb from your Level",
"Random tense from your Enabled Tenses",
]
)
}
Section("Quick Actions") {
featureRow(
icon: "star.fill", color: .orange,
title: "Common Tenses",
details: [
"Restricts to 6 essential tenses: Present, Preterite, Imperfect, Future, Present Subjunctive, Imperative",
"Still filtered by your Level",
]
)
featureRow(
icon: "exclamationmark.triangle", color: .red,
title: "Weak Verbs",
details: [
"Shows verbs you've struggled with (ease factor < 2.0)",
"Only includes verbs you've reviewed at least once",
"Weakest verbs shown first",
]
)
featureRow(
icon: "wand.and.stars", color: .purple,
title: "Irregularity Drills",
details: [
"Spelling Changes: c->qu, g->gu, z->c patterns",
"Stem Changes: e->ie, o->ue, e->i patterns",
"Unique Irregulars: ser, ir, haber, etc.",
"Filtered by your Level and Enabled Tenses",
]
)
featureRow(
icon: "rectangle.stack.fill", color: .teal,
title: "Vocab Review",
details: [
"Reviews vocabulary cards that are due (SRS scheduled)",
"Cards become due after you study them in Course quizzes",
"Rate Again/Hard/Good/Easy to schedule next review",
"Uses all course vocabulary, not filtered by level",
]
)
}
Section("Practice Activities") {
featureRow(
icon: "bubble.left.and.bubble.right.fill", color: .green,
title: "Conversation",
details: [
"AI chat partner using Apple Intelligence (on-device)",
"10 scenario types (restaurant, directions, etc.)",
"AI adapts vocabulary to your Level setting",
"Corrections provided inline when you make mistakes",
"Conversations saved to iCloud for revisiting",
"Requires Apple Intelligence-capable device",
]
)
featureRow(
icon: "ear.fill", color: .blue,
title: "Listening",
details: [
"Listen & Type: hear a sentence, type what you heard",
"Pronunciation: read a sentence aloud, get scored on accuracy",
"Sentences pulled from course vocabulary examples",
"Uses all course vocab (not filtered by level)",
"Pronunciation requires microphone permission",
]
)
featureRow(
icon: "text.badge.minus", color: .indigo,
title: "Cloze Practice",
details: [
"Fill in the missing word in a Spanish sentence",
"Sentences from course vocabulary examples",
"4 multiple-choice options (1 correct + 3 distractors)",
"Distractors are other vocabulary words from same pool",
"Uses all course vocab (not filtered by level)",
]
)
featureRow(
icon: "music.note.list", color: .pink,
title: "Lyrics",
details: [
"Search and save Spanish song lyrics",
"Side-by-side Spanish and English translations",
"User-curated library, not filtered by level",
"Saved to iCloud for sync across devices",
]
)
featureRow(
icon: "book.fill", color: .teal,
title: "Stories",
details: [
"AI-generated one-paragraph Spanish stories",
"Matched to your Level and Enabled Tenses",
"Every word is tappable for definition",
"Known words use offline dictionary (175K+ verb forms)",
"Unknown words looked up via on-device AI",
"English translation hidden by default (toggle to reveal)",
"3-question comprehension quiz at the end",
"Saved to iCloud for revisiting",
"Requires Apple Intelligence-capable device",
]
)
}
Section("Guide") {
featureRow(
icon: "book", color: .brown,
title: "Tense Guides",
details: [
"Detailed explanation of each of the 20 verb tenses",
"Conjugation ending tables for -ar, -er, -ir verbs",
"Usage patterns with example sentences",
"Essential tenses marked with orange badge",
]
)
featureRow(
icon: "doc.text", color: .brown,
title: "Grammar Notes",
details: [
"23 grammar topics (ser vs estar, por vs para, etc.)",
"Interactive exercises available for 5 topics",
"Tap 'Practice This' on notes that have exercises",
"Content grouped by category with card-based layout",
]
)
}
Section("Course") {
featureRow(
icon: "list.clipboard", color: .orange,
title: "Course Quizzes",
details: [
"Vocabulary from specific course weeks",
"Multiple quiz types: MC, typing, handwriting, cloze",
"Focus Area mode for missed words",
"Not filtered by Level (uses course structure)",
]
)
featureRow(
icon: "checkmark.seal", color: .orange,
title: "Checkpoint Exams",
details: [
"Cumulative review across multiple weeks",
"Cards sampled evenly across all covered weeks",
"Choose 25, 50, or 100 questions",
]
)
}
Section("Dashboard") {
featureRow(
icon: "clock.fill", color: .mint,
title: "Study Time",
details: [
"Tracks time the app is in the foreground",
"Starts when app becomes active, stops on background",
"Shows today's time and all-time total",
"7-day bar chart of daily study time",
]
)
}
Section("Settings That Affect Practice") {
settingRow(name: "Level", affects: "Verb practice, Full Table, Quick Actions, Stories, Conversation")
settingRow(name: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories")
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions")
settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")
}
}
.navigationTitle("How Features Work")
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Components
@ViewBuilder
private func featureRow(icon: String, color: Color, title: String, details: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.body)
.foregroundStyle(color)
.frame(width: 24)
Text(title)
.font(.subheadline.weight(.semibold))
}
VStack(alignment: .leading, spacing: 4) {
ForEach(details, id: \.self) { detail in
HStack(alignment: .top, spacing: 6) {
Text("")
.font(.caption)
.foregroundStyle(.secondary)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.leading, 34)
}
.padding(.vertical, 4)
}
@ViewBuilder
private func settingRow(name: String, affects: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(name)
.font(.subheadline.weight(.semibold))
Text(affects)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}

View File

@@ -75,6 +75,12 @@ struct SettingsView: View {
}
}
Section("Reference") {
NavigationLink("How Features Work") {
FeatureReferenceView()
}
}
Section("About") {
LabeledContent("Version", value: "1.0.0")
}

File diff suppressed because one or more lines are too long

View File

@@ -8486,7 +8486,7 @@
"cards": [
{
"front": "tener",
"back": "tengo",
"back": "tengo — I have",
"examples": [
{
"es": "The Spanish Verb \"Tener\"",
@@ -8504,7 +8504,7 @@
},
{
"front": "venir",
"back": "vengo",
"back": "vengo — I come",
"examples": [
{
"es": "Lo mejor está por venir.",
@@ -8522,7 +8522,7 @@
},
{
"front": "hacer",
"back": "hago",
"back": "hago — I do, I make",
"examples": [
{
"es": "Expressions with \"Hacer\"",
@@ -8540,7 +8540,7 @@
},
{
"front": "salir",
"back": "salgo",
"back": "salgo — I go out",
"examples": [
{
"es": "Usa el ascensor para salir.",
@@ -8558,7 +8558,7 @@
},
{
"front": "caer",
"back": "caigo",
"back": "caigo — I fall",
"examples": [
{
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
@@ -8576,7 +8576,7 @@
},
{
"front": "traer",
"back": "traigo",
"back": "traigo — I bring",
"examples": [
{
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
@@ -8594,7 +8594,7 @@
},
{
"front": "poner",
"back": "pongo",
"back": "pongo — I put",
"examples": [
{
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
@@ -8612,7 +8612,7 @@
},
{
"front": "decir",
"back": "digo",
"back": "digo — I say",
"examples": [
{
"es": "¿Jura decir la verdad?",
@@ -8630,7 +8630,7 @@
},
{
"front": "conducir",
"back": "conduzco",
"back": "conduzco — I lead, I drive",
"examples": [
{
"es": "conducir(kohn-doo-seer)",
@@ -8648,7 +8648,7 @@
},
{
"front": "conocer",
"back": "conozco",
"back": "conozco — I know, I meet",
"examples": [
{
"es": "conocer(koh-noh-sehr)",
@@ -8666,7 +8666,7 @@
},
{
"front": "agradecer",
"back": "agradezco",
"back": "agradezco — I thank",
"examples": [
{
"es": "agradecer(ah-grah-deh-sehr)",
@@ -8684,7 +8684,7 @@
},
{
"front": "parecer",
"back": "parezco",
"back": "parezco — I seem",
"examples": [
{
"es": "parecer(pah-reh-sehr)",
@@ -8702,7 +8702,7 @@
},
{
"front": "crecer",
"back": "crezco",
"back": "crezco — I grow",
"examples": [
{
"es": "crecer(kreh-sehr)",
@@ -8720,7 +8720,7 @@
},
{
"front": "producir",
"back": "produzco",
"back": "produzco — I produce",
"examples": [
{
"es": "producir(proh-doo-seer)",
@@ -8738,7 +8738,7 @@
},
{
"front": "traducir",
"back": "traduzco",
"back": "traduzco — I translate",
"examples": [
{
"es": "traducir(trah-doo-seer)",
@@ -8756,7 +8756,7 @@
},
{
"front": "establecer",
"back": "establezco",
"back": "establezco — I establish",
"examples": [
{
"es": "establecer(ehs-tah-bleh-sehr)",
@@ -8774,7 +8774,7 @@
},
{
"front": "elejir",
"back": "elijo",
"back": "elijo — I choose",
"examples": [
{
"es": "En realidad cada persona será libre de elejir su comida.",
@@ -8792,7 +8792,7 @@
},
{
"front": "proteger",
"back": "protejo",
"back": "protejo — I protect",
"examples": [
{
"es": "proteger(proh-teh-hehr)",
@@ -8810,7 +8810,7 @@
},
{
"front": "dirigir",
"back": "dirijo",
"back": "dirijo — I manage, I direct",
"examples": [
{
"es": "dirigir(dee-ree-heer)",
@@ -8828,7 +8828,7 @@
},
{
"front": "fingir",
"back": "finjo",
"back": "finjo — I pretend, I feign",
"examples": [
{
"es": "fingir(feen-heer)",
@@ -8846,7 +8846,7 @@
},
{
"front": "sumergir",
"back": "sumerjo",
"back": "sumerjo — I submerge",
"examples": [
{
"es": "sumergir(soo-mehr-heer)",
@@ -8864,7 +8864,7 @@
},
{
"front": "ver",
"back": "veo",
"back": "veo — I see",
"examples": [
{
"es": "¿Quieres ver mi carro nuevo?",
@@ -8882,7 +8882,7 @@
},
{
"front": "saber",
"back": "sé",
"back": "sé — I know, I taste",
"examples": [
{
"es": "El saber popular se basa en creencias.",
@@ -8900,7 +8900,7 @@
},
{
"front": "distinguir",
"back": "distingo",
"back": "distingo — I distinguish",
"examples": [
{
"es": "distinguir(dees-teeng-geer)",
@@ -8918,7 +8918,7 @@
},
{
"front": "oír",
"back": "oigo",
"back": "oigo — I hear",
"examples": [
{
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",
@@ -8943,7 +8943,7 @@
"cards": [
{
"front": "tener",
"back": "tengo",
"back": "tengo — I have",
"examples": [
{
"es": "The Spanish Verb \"Tener\"",
@@ -8961,7 +8961,7 @@
},
{
"front": "venir",
"back": "vengo",
"back": "vengo — I come",
"examples": [
{
"es": "Lo mejor está por venir.",
@@ -8979,7 +8979,7 @@
},
{
"front": "hacer",
"back": "hago",
"back": "hago — I do, I make",
"examples": [
{
"es": "Expressions with \"Hacer\"",
@@ -8997,7 +8997,7 @@
},
{
"front": "salir",
"back": "salgo",
"back": "salgo — I go out",
"examples": [
{
"es": "Usa el ascensor para salir.",
@@ -9015,7 +9015,7 @@
},
{
"front": "caer",
"back": "caigo",
"back": "caigo — I fall",
"examples": [
{
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
@@ -9033,7 +9033,7 @@
},
{
"front": "traer",
"back": "traigo",
"back": "traigo — I bring",
"examples": [
{
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
@@ -9051,7 +9051,7 @@
},
{
"front": "poner",
"back": "pongo",
"back": "pongo — I put",
"examples": [
{
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
@@ -9069,7 +9069,7 @@
},
{
"front": "decir",
"back": "digo",
"back": "digo — I say",
"examples": [
{
"es": "¿Jura decir la verdad?",
@@ -9087,7 +9087,7 @@
},
{
"front": "conducir",
"back": "conduzco",
"back": "conduzco — I lead, I drive",
"examples": [
{
"es": "conducir(kohn-doo-seer)",
@@ -9105,7 +9105,7 @@
},
{
"front": "conocer",
"back": "conozco",
"back": "conozco — I know, I meet",
"examples": [
{
"es": "conocer(koh-noh-sehr)",
@@ -9123,7 +9123,7 @@
},
{
"front": "agradecer",
"back": "agradezco",
"back": "agradezco — I thank",
"examples": [
{
"es": "agradecer(ah-grah-deh-sehr)",
@@ -9141,7 +9141,7 @@
},
{
"front": "parecer",
"back": "parezco",
"back": "parezco — I seem",
"examples": [
{
"es": "parecer(pah-reh-sehr)",
@@ -9159,7 +9159,7 @@
},
{
"front": "crecer",
"back": "crezco",
"back": "crezco — I grow",
"examples": [
{
"es": "crecer(kreh-sehr)",
@@ -9177,7 +9177,7 @@
},
{
"front": "producir",
"back": "produzco",
"back": "produzco — I produce",
"examples": [
{
"es": "producir(proh-doo-seer)",
@@ -9195,7 +9195,7 @@
},
{
"front": "traducir",
"back": "traduzco",
"back": "traduzco — I translate",
"examples": [
{
"es": "traducir(trah-doo-seer)",
@@ -9213,7 +9213,7 @@
},
{
"front": "establecer",
"back": "establezco",
"back": "establezco — I establish",
"examples": [
{
"es": "establecer(ehs-tah-bleh-sehr)",
@@ -9231,7 +9231,7 @@
},
{
"front": "elejir",
"back": "elijo",
"back": "elijo — I choose",
"examples": [
{
"es": "En realidad cada persona será libre de elejir su comida.",
@@ -9249,7 +9249,7 @@
},
{
"front": "proteger",
"back": "protejo",
"back": "protejo — I protect",
"examples": [
{
"es": "proteger(proh-teh-hehr)",
@@ -9267,7 +9267,7 @@
},
{
"front": "dirigir",
"back": "dirijo",
"back": "dirijo — I manage, I direct",
"examples": [
{
"es": "dirigir(dee-ree-heer)",
@@ -9285,7 +9285,7 @@
},
{
"front": "fingir",
"back": "finjo",
"back": "finjo — I pretend, I feign",
"examples": [
{
"es": "fingir(feen-heer)",
@@ -9303,7 +9303,7 @@
},
{
"front": "sumergir",
"back": "sumerjo",
"back": "sumerjo — I submerge",
"examples": [
{
"es": "sumergir(soo-mehr-heer)",
@@ -9321,7 +9321,7 @@
},
{
"front": "ver",
"back": "veo",
"back": "veo — I see",
"examples": [
{
"es": "¿Quieres ver mi carro nuevo?",
@@ -9339,7 +9339,7 @@
},
{
"front": "saber",
"back": "sé",
"back": "sé — I know, I taste",
"examples": [
{
"es": "El saber popular se basa en creencias.",
@@ -9357,7 +9357,7 @@
},
{
"front": "distinguir",
"back": "distingo",
"back": "distingo — I distinguish",
"examples": [
{
"es": "distinguir(dees-teeng-geer)",
@@ -9375,7 +9375,7 @@
},
{
"front": "oír",
"back": "oigo",
"back": "oigo — I hear",
"examples": [
{
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",

View File

@@ -0,0 +1,48 @@
import SwiftData
import Foundation
@Model
public final class Conversation {
public var id: String = ""
public var scenario: String = ""
public var level: String = ""
public var messages: String = "[]"
public var createdDate: Date = Date()
public init(scenario: String, level: String) {
self.id = UUID().uuidString
self.scenario = scenario
self.level = level
self.messages = "[]"
self.createdDate = Date()
}
}
public struct ChatMessage: Codable, Identifiable, Hashable {
public var id: String
public let role: String // "assistant" or "user"
public let content: String
public let correction: String?
public init(role: String, content: String, correction: String? = nil) {
self.id = UUID().uuidString
self.role = role
self.content = content
self.correction = correction
}
}
extension Conversation {
public var decodedMessages: [ChatMessage] {
guard let data = messages.data(using: .utf8) else { return [] }
return (try? JSONDecoder().decode([ChatMessage].self, from: data)) ?? []
}
public func appendMessage(_ message: ChatMessage) {
var msgs = decodedMessages
msgs.append(message)
if let data = try? JSONEncoder().encode(msgs), let str = String(data: data, encoding: .utf8) {
messages = str
}
}
}