Compare commits
14 Commits
0848675016
...
issue/19-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b69f3b630 | ||
|
|
ff4f906128 | ||
| 23ff9d66de | |||
|
|
b48e935231 | ||
| 924090190f | |||
|
|
945b2ff1f3 | ||
| 77932f802a | |||
|
|
5944f263cd | ||
|
|
a3318adf5e | ||
|
|
a3807faf2d | ||
| 93ab7b3e16 | |||
|
|
a663bc03cd | ||
| b13f58ec81 | |||
|
|
451866e988 |
@@ -79,6 +79,21 @@
|
||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
|
||||
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; };
|
||||
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327659ABFD524514B6D2D505 /* StoryGenerator.swift */; };
|
||||
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 */
|
||||
@@ -182,6 +197,21 @@
|
||||
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
|
||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
||||
327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
|
||||
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 */
|
||||
@@ -226,6 +256,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
||||
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
@@ -248,6 +279,10 @@
|
||||
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 */,
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||
@@ -280,7 +315,8 @@
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||
);
|
||||
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -331,10 +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>";
|
||||
};
|
||||
@@ -343,9 +384,29 @@
|
||||
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;
|
||||
children = (
|
||||
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */,
|
||||
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */,
|
||||
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */,
|
||||
);
|
||||
path = Stories;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
|
||||
isa = PBXGroup;
|
||||
@@ -593,6 +654,21 @@
|
||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */,
|
||||
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */,
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
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,
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
569
Conjuga/Conjuga/Models/GrammarExercise.swift
Normal file
569
Conjuga/Conjuga/Models/GrammarExercise.swift
Normal 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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
78
Conjuga/Conjuga/Services/ConversationService.swift
Normal file
78
Conjuga/Conjuga/Services/ConversationService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
268
Conjuga/Conjuga/Services/DictionaryService.swift
Normal file
268
Conjuga/Conjuga/Services/DictionaryService.swift
Normal 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"), ("tú", "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"), ("sí", "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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
155
Conjuga/Conjuga/Services/PronunciationService.swift
Normal file
155
Conjuga/Conjuga/Services/PronunciationService.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
97
Conjuga/Conjuga/Services/StoryGenerator.swift
Normal file
97
Conjuga/Conjuga/Services/StoryGenerator.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
struct StoryGenerator {
|
||||
|
||||
// MARK: - Generable Types
|
||||
|
||||
@Generable
|
||||
struct GeneratedStory {
|
||||
@Guide(description: "A short creative title for the story in Spanish, 3-6 words")
|
||||
var title: String
|
||||
|
||||
@Guide(description: "A one-paragraph story in Spanish, 5-8 sentences long, using vocabulary and grammar appropriate for the student level")
|
||||
var bodyES: String
|
||||
|
||||
@Guide(description: "An accurate English translation of bodyES")
|
||||
var bodyEN: String
|
||||
|
||||
@Guide(description: "Every word from the story annotated with its base form, English meaning, and part of speech. Include articles, prepositions, and all other words.")
|
||||
var words: [GeneratedAnnotation]
|
||||
|
||||
@Guide(description: "3 reading comprehension questions about the story, each with 4 answer options in Spanish", .count(3))
|
||||
var questions: [GeneratedQuestion]
|
||||
}
|
||||
|
||||
@Generable
|
||||
struct GeneratedAnnotation {
|
||||
@Guide(description: "The exact word as it appears in the story")
|
||||
var word: String
|
||||
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
|
||||
var baseForm: String
|
||||
@Guide(description: "English translation of the word")
|
||||
var english: String
|
||||
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, or other")
|
||||
var partOfSpeech: String
|
||||
}
|
||||
|
||||
@Generable
|
||||
struct GeneratedQuestion {
|
||||
@Guide(description: "A comprehension question about the story in Spanish")
|
||||
var question: String
|
||||
@Guide(description: "4 answer options in Spanish", .count(4))
|
||||
var options: [String]
|
||||
@Guide(description: "Index of the correct answer (0-3)", .range(0...3))
|
||||
var correctIndex: Int
|
||||
}
|
||||
|
||||
// MARK: - Generation
|
||||
|
||||
static func generate(level: String, tenses: [String]) async throws -> Story {
|
||||
let tenseNames = tenses.isEmpty
|
||||
? "present, preterite, imperfect, and future"
|
||||
: tenses.joined(separator: ", ")
|
||||
|
||||
let session = LanguageModelSession(instructions: """
|
||||
You are a Spanish language teacher creating a short reading exercise.
|
||||
The student's level is: \(level).
|
||||
Focus on these verb tenses: \(tenseNames).
|
||||
Write naturally but keep vocabulary appropriate for the level.
|
||||
Use common, everyday scenarios (shopping, travel, family, school, work, food).
|
||||
The story should be exactly one paragraph of 5-8 sentences.
|
||||
""")
|
||||
|
||||
let response = try await session.respond(
|
||||
to: "Create a short Spanish story for reading practice.",
|
||||
generating: GeneratedStory.self
|
||||
)
|
||||
|
||||
let story = response.content
|
||||
|
||||
let annotations = story.words.map {
|
||||
WordAnnotation(word: $0.word, baseForm: $0.baseForm, english: $0.english, partOfSpeech: $0.partOfSpeech)
|
||||
}
|
||||
let questions = story.questions.map {
|
||||
QuizQuestion(question: $0.question, options: $0.options, correctIndex: $0.correctIndex)
|
||||
}
|
||||
|
||||
let annotationsJSON = (try? String(data: JSONEncoder().encode(annotations), encoding: .utf8)) ?? "[]"
|
||||
let questionsJSON = (try? String(data: JSONEncoder().encode(questions), encoding: .utf8)) ?? "[]"
|
||||
|
||||
return Story(
|
||||
title: story.title,
|
||||
bodyES: story.bodyES,
|
||||
bodyEN: story.bodyEN,
|
||||
level: level,
|
||||
wordAnnotations: annotationsJSON,
|
||||
quizQuestions: questionsJSON
|
||||
)
|
||||
}
|
||||
|
||||
static var isAvailable: Bool {
|
||||
SystemLanguageModel.default.availability == .available
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
164
Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift
Normal file
164
Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
126
Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift
Normal file
126
Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
150
Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift
Normal file
150
Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
212
Conjuga/Conjuga/Views/Practice/ClozeView.swift
Normal file
212
Conjuga/Conjuga/Views/Practice/ClozeView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
319
Conjuga/Conjuga/Views/Practice/ListeningView.swift
Normal file
319
Conjuga/Conjuga/Views/Practice/ListeningView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -129,12 +129,176 @@ 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()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "book.fill")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.teal)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Stories")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Read AI-generated Spanish stories")
|
||||
.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)
|
||||
|
||||
// Quick Actions
|
||||
VStack(spacing: 12) {
|
||||
Text("Quick Actions")
|
||||
.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
|
||||
|
||||
134
Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift
Normal file
134
Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct StoryLibraryView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var stories: [Story] = []
|
||||
@State private var isGenerating = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if stories.isEmpty && !isGenerating {
|
||||
ContentUnavailableView(
|
||||
"No Stories Yet",
|
||||
systemImage: "book.closed",
|
||||
description: Text("Tap + to generate a Spanish story with AI.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
if isGenerating {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Generating story...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
ForEach(stories) { story in
|
||||
NavigationLink {
|
||||
StoryReaderView(story: story)
|
||||
} label: {
|
||||
StoryRowView(story: story)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteStories)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Stories")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
generateStory()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.disabled(isGenerating || !StoryGenerator.isAvailable)
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK") { errorMessage = nil }
|
||||
} message: {
|
||||
Text(errorMessage ?? "")
|
||||
}
|
||||
.onAppear(perform: loadStories)
|
||||
}
|
||||
|
||||
private func loadStories() {
|
||||
let descriptor = FetchDescriptor<Story>(
|
||||
sortBy: [SortDescriptor(\Story.createdDate, order: .reverse)]
|
||||
)
|
||||
stories = (try? cloudModelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
private func generateStory() {
|
||||
guard !isGenerating else { return }
|
||||
|
||||
guard StoryGenerator.isAvailable else {
|
||||
errorMessage = "Apple Intelligence is not available on this device. Stories require an iPhone 15 Pro or later with Apple Intelligence enabled."
|
||||
return
|
||||
}
|
||||
|
||||
isGenerating = true
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
let level = progress.selectedLevel
|
||||
let tenses = progress.enabledTenseIDs
|
||||
|
||||
Task {
|
||||
do {
|
||||
let story = try await StoryGenerator.generate(level: level, tenses: tenses)
|
||||
cloudModelContext.insert(story)
|
||||
try? cloudModelContext.save()
|
||||
loadStories()
|
||||
} catch {
|
||||
errorMessage = "Failed to generate story: \(error.localizedDescription)"
|
||||
}
|
||||
isGenerating = false
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteStories(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
cloudModelContext.delete(stories[index])
|
||||
}
|
||||
try? cloudModelContext.save()
|
||||
loadStories()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Story Row
|
||||
|
||||
private struct StoryRowView: View {
|
||||
let story: Story
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(story.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(story.level.capitalized)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.teal)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.teal.opacity(0.12), in: Capsule())
|
||||
|
||||
Text(story.createdDate.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
152
Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift
Normal file
152
Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
|
||||
struct StoryQuizView: View {
|
||||
let story: Story
|
||||
|
||||
@State private var currentIndex = 0
|
||||
@State private var selectedOption: Int?
|
||||
@State private var correctCount = 0
|
||||
@State private var isFinished = false
|
||||
|
||||
private var questions: [QuizQuestion] { story.decodedQuestions }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
if isFinished {
|
||||
finishedView
|
||||
} else if let question = questions[safe: currentIndex] {
|
||||
questionView(question)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 600)
|
||||
.navigationTitle("Comprehension Quiz")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Question View
|
||||
|
||||
@ViewBuilder
|
||||
private func questionView(_ question: QuizQuestion) -> some View {
|
||||
VStack(spacing: 20) {
|
||||
// Progress
|
||||
Text("Question \(currentIndex + 1) of \(questions.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Question
|
||||
Text(question.question)
|
||||
.font(.title3.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
// Options
|
||||
VStack(spacing: 10) {
|
||||
ForEach(Array(question.options.enumerated()), id: \.offset) { index, option in
|
||||
Button {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = index
|
||||
if index == question.correctIndex {
|
||||
correctCount += 1
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(option)
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer()
|
||||
if let selected = selectedOption {
|
||||
if index == question.correctIndex {
|
||||
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(optionBackground(index: index, correct: question.correctIndex), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Next button
|
||||
if selectedOption != nil {
|
||||
Button {
|
||||
if currentIndex + 1 < questions.count {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
Text(currentIndex + 1 < questions.count ? "Next Question" : "See Results")
|
||||
.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: correctCount == questions.count ? "star.fill" : "checkmark.circle")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(correctCount == questions.count ? .yellow : .teal)
|
||||
|
||||
Text("\(correctCount) / \(questions.count)")
|
||||
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||
|
||||
Text(scoreMessage)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var scoreMessage: String {
|
||||
switch correctCount {
|
||||
case questions.count: return "Perfect score!"
|
||||
case _ where correctCount > questions.count / 2: return "Good job! Keep reading."
|
||||
default: return "Try re-reading the story and quiz again."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func optionBackground(index: Int, correct: Int) -> some ShapeStyle {
|
||||
guard let selected = selectedOption else {
|
||||
return AnyShapeStyle(.fill.quaternary)
|
||||
}
|
||||
if 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
|
||||
}
|
||||
}
|
||||
333
Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift
Normal file
333
Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift
Normal file
@@ -0,0 +1,333 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
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] = [:]
|
||||
|
||||
private var annotations: [WordAnnotation] { story.decodedAnnotations }
|
||||
private var annotationMap: [String: WordAnnotation] {
|
||||
Dictionary(annotations.map { (cleanWord($0.word), $0) }, uniquingKeysWith: { first, _ in first })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Title
|
||||
Text(story.title)
|
||||
.font(.title2.bold())
|
||||
|
||||
// Level badge
|
||||
Text(story.level.capitalized)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.teal)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.teal.opacity(0.12), in: Capsule())
|
||||
|
||||
Divider()
|
||||
|
||||
// Tappable Spanish text
|
||||
tappableText
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
// Translation toggle
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button {
|
||||
withAnimation { showTranslation.toggle() }
|
||||
} label: {
|
||||
Label(
|
||||
showTranslation ? "Hide Translation" : "Show Translation",
|
||||
systemImage: showTranslation ? "eye.slash" : "eye"
|
||||
)
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.tint(.secondary)
|
||||
|
||||
if showTranslation {
|
||||
Text(story.bodyEN)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
|
||||
// Quiz button
|
||||
if !story.decodedQuestions.isEmpty {
|
||||
NavigationLink {
|
||||
StoryQuizView(story: story)
|
||||
} label: {
|
||||
Label("Take Comprehension Quiz", systemImage: "questionmark.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.teal)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 800)
|
||||
}
|
||||
.navigationTitle("Story")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $selectedWord) { word in
|
||||
WordDetailSheet(word: word)
|
||||
.presentationDetents([.height(200)])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tappable Text
|
||||
|
||||
private var tappableText: some View {
|
||||
let words = story.bodyES.components(separatedBy: " ")
|
||||
let map = annotationMap
|
||||
let cache = lookupCache
|
||||
let context = story.bodyES
|
||||
|
||||
return WrappingHStack(words: words) { word in
|
||||
WordButton(word: word, map: map, cache: cache) { ann in
|
||||
if ann.english.isEmpty {
|
||||
lookupWord(ann.word, inContext: context)
|
||||
} else {
|
||||
selectedWord = ann
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lookupWord(_ word: String, inContext sentence: String) {
|
||||
// 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 {
|
||||
do {
|
||||
let annotation = try await WordLookup.lookup(word: word, inContext: sentence)
|
||||
lookupCache[word] = annotation
|
||||
selectedWord = annotation
|
||||
} catch {
|
||||
selectedWord = WordAnnotation(word: word, baseForm: word, english: "Lookup unavailable", partOfSpeech: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanWord(_ word: String) -> String {
|
||||
word.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word Button
|
||||
|
||||
private struct WordButton: View {
|
||||
let word: String
|
||||
let map: [String: WordAnnotation]
|
||||
let cache: [String: WordAnnotation]
|
||||
let onTap: (WordAnnotation) -> Void
|
||||
|
||||
private var cleaned: String {
|
||||
word.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
private var resolvedAnnotation: WordAnnotation {
|
||||
map[cleaned] ?? cache[cleaned] ?? WordAnnotation(word: cleaned, baseForm: cleaned, english: "", partOfSpeech: "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
onTap(resolvedAnnotation)
|
||||
} label: {
|
||||
Text(word + " ")
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.underline(true, color: .teal.opacity(0.3))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wrapping HStack
|
||||
|
||||
private struct WrappingHStack<Content: View>: View {
|
||||
let words: [String]
|
||||
let content: (String) -> Content
|
||||
|
||||
var body: some View {
|
||||
FlowLayout(spacing: 0) {
|
||||
ForEach(Array(words.enumerated()), id: \.offset) { _, word in
|
||||
content(word)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
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 subviewIndex = 0
|
||||
for row in rows {
|
||||
var x = bounds.minX
|
||||
let rowHeight = row.map { $0.height }.max() ?? 0
|
||||
for size in row {
|
||||
subviews[subviewIndex].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width
|
||||
subviewIndex += 1
|
||||
}
|
||||
y += rowHeight + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var rows: [[CGSize]] = [[]]
|
||||
var currentWidth: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if currentWidth + size.width > maxWidth && !rows[rows.count - 1].isEmpty {
|
||||
rows.append([])
|
||||
currentWidth = 0
|
||||
}
|
||||
rows[rows.count - 1].append(size)
|
||||
currentWidth += size.width
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word Detail Sheet
|
||||
|
||||
private struct WordDetailSheet: View {
|
||||
let word: WordAnnotation
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
Text(word.word)
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
if !word.partOfSpeech.isEmpty {
|
||||
Text(word.partOfSpeech)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if word.english == "Looking up..." {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Looking up word...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !word.baseForm.isEmpty && word.baseForm != word.word {
|
||||
HStack {
|
||||
Text("Base form:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.baseForm)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
|
||||
if !word.english.isEmpty {
|
||||
HStack {
|
||||
Text("English:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.english)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-Demand Word Lookup
|
||||
|
||||
@MainActor
|
||||
private enum WordLookup {
|
||||
@Generable
|
||||
struct WordInfo {
|
||||
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
|
||||
var baseForm: String
|
||||
@Guide(description: "English translation")
|
||||
var english: String
|
||||
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, conjunction, article, pronoun, or other")
|
||||
var partOfSpeech: String
|
||||
}
|
||||
|
||||
static func lookup(word: String, inContext sentence: String) async throws -> WordAnnotation {
|
||||
let session = LanguageModelSession(instructions: """
|
||||
You are a Spanish dictionary. Given a word and the sentence it appears in, \
|
||||
provide its base form, English translation, and part of speech.
|
||||
""")
|
||||
|
||||
let response = try await session.respond(
|
||||
to: "Word: \"\(word)\" in sentence: \"\(sentence)\"",
|
||||
generating: WordInfo.self
|
||||
)
|
||||
|
||||
let info = response.content
|
||||
return WordAnnotation(
|
||||
word: word,
|
||||
baseForm: info.baseForm,
|
||||
english: info.english,
|
||||
partOfSpeech: info.partOfSpeech
|
||||
)
|
||||
}
|
||||
}
|
||||
180
Conjuga/Conjuga/Views/Practice/VocabReviewView.swift
Normal file
180
Conjuga/Conjuga/Views/Practice/VocabReviewView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
252
Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift
Normal file
252
Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
48
Conjuga/SharedModels/Sources/SharedModels/Conversation.swift
Normal file
48
Conjuga/SharedModels/Sources/SharedModels/Conversation.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
67
Conjuga/SharedModels/Sources/SharedModels/Story.swift
Normal file
67
Conjuga/SharedModels/Sources/SharedModels/Story.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
@Model
|
||||
public final class Story {
|
||||
public var id: String = ""
|
||||
public var title: String = ""
|
||||
public var bodyES: String = ""
|
||||
public var bodyEN: String = ""
|
||||
public var level: String = ""
|
||||
public var wordAnnotations: String = "[]"
|
||||
public var quizQuestions: String = "[]"
|
||||
public var createdDate: Date = Date()
|
||||
|
||||
public init(title: String, bodyES: String, bodyEN: String, level: String, wordAnnotations: String, quizQuestions: String) {
|
||||
self.id = UUID().uuidString
|
||||
self.title = title
|
||||
self.bodyES = bodyES
|
||||
self.bodyEN = bodyEN
|
||||
self.level = level
|
||||
self.wordAnnotations = wordAnnotations
|
||||
self.quizQuestions = quizQuestions
|
||||
self.createdDate = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON Helpers
|
||||
|
||||
public struct WordAnnotation: Codable, Identifiable, Hashable {
|
||||
public var id: String { word }
|
||||
public let word: String
|
||||
public let baseForm: String
|
||||
public let english: String
|
||||
public let partOfSpeech: String
|
||||
|
||||
public init(word: String, baseForm: String, english: String, partOfSpeech: String) {
|
||||
self.word = word
|
||||
self.baseForm = baseForm
|
||||
self.english = english
|
||||
self.partOfSpeech = partOfSpeech
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuizQuestion: Codable, Identifiable, Hashable {
|
||||
public var id: String { question }
|
||||
public let question: String
|
||||
public let options: [String]
|
||||
public let correctIndex: Int
|
||||
|
||||
public init(question: String, options: [String], correctIndex: Int) {
|
||||
self.question = question
|
||||
self.options = options
|
||||
self.correctIndex = correctIndex
|
||||
}
|
||||
}
|
||||
|
||||
extension Story {
|
||||
public var decodedAnnotations: [WordAnnotation] {
|
||||
guard let data = wordAnnotations.data(using: .utf8) else { return [] }
|
||||
return (try? JSONDecoder().decode([WordAnnotation].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
public var decodedQuestions: [QuizQuestion] {
|
||||
guard let data = quizQuestions.data(using: .utf8) else { return [] }
|
||||
return (try? JSONDecoder().decode([QuizQuestion].self, from: data)) ?? []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user