Add 6 new practice features, offline dictionary, and feature reference
New features: - Offline Dictionary: reverse index of 175K verb forms + 200 common words, cached to disk, powers instant word lookups in Stories - Vocab SRS Review: spaced repetition for course vocabulary cards with due count badge and Again/Hard/Good/Easy rating - Cloze Practice: fill-in-the-blank using SentenceQuizEngine with distractor generation from vocabulary pool - Grammar Exercises: interactive quizzes for 5 grammar topics (ser/estar, por/para, preterite/imperfect, subjunctive, personal a) with "Practice This" button on grammar note detail - Listening Practice: listen-and-type + pronunciation check modes using Speech framework with word-by-word match scoring - Conversational Practice: AI chat partner via Foundation Models with 10 scenario types, saved to cloud container Other changes: - Add Conversation model to SharedModels and cloud container - Add Info.plist keys for speech recognition and microphone - Skip speech auth on simulator to prevent crash - Fix preparing data screen to only show during seed/migration - Extract courseDataVersion to static property on DataLoader - Add "How Features Work" reference page in Settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,17 @@
|
||||
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; };
|
||||
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; };
|
||||
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; };
|
||||
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */; };
|
||||
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3698CE7ACF148318615293E /* VocabReviewView.swift */; };
|
||||
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A649B04B8B3C49419AD9219C /* ClozeView.swift */; };
|
||||
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */; };
|
||||
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */; };
|
||||
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D535EF6988A24B47B70209A2 /* PronunciationService.swift */; };
|
||||
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2179562E54E148C98219D /* ListeningView.swift */; };
|
||||
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10603F454E54341AA4B9931 /* ConversationService.swift */; };
|
||||
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */; };
|
||||
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */; };
|
||||
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -190,6 +201,17 @@
|
||||
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
|
||||
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
|
||||
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
|
||||
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
|
||||
D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
|
||||
A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
|
||||
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
|
||||
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
|
||||
D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
|
||||
02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
|
||||
E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
|
||||
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
|
||||
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -234,6 +256,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
||||
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
@@ -256,6 +279,9 @@
|
||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||
E10603F454E54341AA4B9931 /* ConversationService.swift */,
|
||||
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
|
||||
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
|
||||
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
|
||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
|
||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||
@@ -289,7 +315,8 @@
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||
);
|
||||
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -340,11 +367,15 @@
|
||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
||||
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||
8A1DED0596E04DDE9536A9A9 /* Stories */,
|
||||
);
|
||||
DFD75E32A53845A693D98F48 /* Chat */,
|
||||
02B2179562E54E148C98219D /* ListeningView.swift */,
|
||||
A649B04B8B3C49419AD9219C /* ClozeView.swift */,
|
||||
);
|
||||
path = Practice;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -353,9 +384,19 @@
|
||||
children = (
|
||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
||||
);
|
||||
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */,
|
||||
);
|
||||
path = Guide;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DFD75E32A53845A693D98F48 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */,
|
||||
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8A1DED0596E04DDE9536A9A9 /* Stories */ = {
|
||||
isa = PBXGroup;
|
||||
@@ -617,6 +658,17 @@
|
||||
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */,
|
||||
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */,
|
||||
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */,
|
||||
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */,
|
||||
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */,
|
||||
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */,
|
||||
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */,
|
||||
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */,
|
||||
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */,
|
||||
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */,
|
||||
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */,
|
||||
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */,
|
||||
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */,
|
||||
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ private enum CloudPreviewContainer {
|
||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
return try! ModelContainer(
|
||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
configurations: configuration
|
||||
)
|
||||
}()
|
||||
@@ -36,9 +36,10 @@ extension EnvironmentValues {
|
||||
struct ConjugaApp: App {
|
||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var isReady = false
|
||||
@State private var isReady = true
|
||||
@State private var syncMonitor = SyncStatusMonitor()
|
||||
@State private var studyTimer = StudyTimerService()
|
||||
@State private var dictionary = DictionaryService()
|
||||
|
||||
let localContainer: ModelContainer
|
||||
let cloudContainer: ModelContainer
|
||||
@@ -67,15 +68,16 @@ struct ConjugaApp: App {
|
||||
"cloud",
|
||||
schema: Schema([
|
||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
]),
|
||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||
)
|
||||
cloudContainer = try ModelContainer(
|
||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
configurations: cloudConfig
|
||||
)
|
||||
|
||||
} catch {
|
||||
fatalError("Failed to create ModelContainer: \(error)")
|
||||
}
|
||||
@@ -108,15 +110,22 @@ struct ConjugaApp: App {
|
||||
.environment(syncMonitor)
|
||||
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
||||
.environment(studyTimer)
|
||||
.environment(dictionary)
|
||||
.task {
|
||||
if let url = SharedStore.localStoreURL() {
|
||||
StoreInspector.dump(at: url, label: "before-bootstrap")
|
||||
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
|
||||
if needsSeed {
|
||||
isReady = false
|
||||
}
|
||||
|
||||
await StartupCoordinator.bootstrap(localContainer: localContainer)
|
||||
if let url = SharedStore.localStoreURL() {
|
||||
StoreInspector.dump(at: url, label: "after-bootstrap")
|
||||
|
||||
if !isReady {
|
||||
isReady = true
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
dictionary.buildIfNeeded(context: localContainer.mainContext)
|
||||
}
|
||||
isReady = true
|
||||
|
||||
Task { @MainActor in
|
||||
syncMonitor.beginSync()
|
||||
@@ -189,7 +198,7 @@ struct ConjugaApp: App {
|
||||
|
||||
deleteStoreFiles(at: url)
|
||||
UserDefaults.standard.removeObject(forKey: "courseDataVersion")
|
||||
print("Reset corrupted local reference store")
|
||||
print("[ConjugaApp] ⚠️ Reset corrupted local reference store — this triggers full re-seed")
|
||||
|
||||
return try makeLocalContainer(at: url)
|
||||
}
|
||||
@@ -250,4 +259,5 @@ struct ConjugaApp: App {
|
||||
|
||||
defaults.set(resetVersion, forKey: key)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
80
Conjuga/Conjuga/Models/GrammarExercise.swift
Normal file
80
Conjuga/Conjuga/Models/GrammarExercise.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
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
|
||||
|
||||
private static let serVsEstarExercises = [
|
||||
GrammarExercise(id: "se1", prompt: "Choose ser or estar:", sentence: "Ella _____ doctora.", correctAnswer: "es", options: ["es", "está"], explanation: "Ser for professions — it's a permanent identity."),
|
||||
GrammarExercise(id: "se2", prompt: "Choose ser or estar:", sentence: "El libro _____ en la mesa.", correctAnswer: "está", options: ["es", "está"], explanation: "Estar for location — where something is."),
|
||||
GrammarExercise(id: "se3", prompt: "Choose ser or estar:", sentence: "Yo _____ muy cansado hoy.", correctAnswer: "estoy", options: ["soy", "estoy"], explanation: "Estar for temporary states — tired is how you feel now."),
|
||||
GrammarExercise(id: "se4", prompt: "Choose ser or estar:", sentence: "Nosotros _____ de México.", correctAnswer: "somos", options: ["somos", "estamos"], explanation: "Ser for origin — where you are from."),
|
||||
GrammarExercise(id: "se5", prompt: "Choose ser or estar:", sentence: "La sopa _____ caliente.", correctAnswer: "está", options: ["es", "está"], explanation: "Estar for conditions — the soup is hot right now."),
|
||||
GrammarExercise(id: "se6", prompt: "Choose ser or estar:", sentence: "_____ las tres de la tarde.", correctAnswer: "Son", options: ["Son", "Están"], explanation: "Ser for telling time."),
|
||||
GrammarExercise(id: "se7", prompt: "Choose ser or estar:", sentence: "Mi hermano _____ alto.", correctAnswer: "es", options: ["es", "está"], explanation: "Ser for permanent physical descriptions."),
|
||||
GrammarExercise(id: "se8", prompt: "Choose ser or estar:", sentence: "Ella _____ feliz porque aprobó.", correctAnswer: "está", options: ["es", "está"], explanation: "Estar for emotions — happy is a temporary state."),
|
||||
]
|
||||
|
||||
// MARK: - Por vs Para
|
||||
|
||||
private static let porVsParaExercises = [
|
||||
GrammarExercise(id: "pp1", prompt: "Choose por or para:", sentence: "Este regalo es _____ ti.", correctAnswer: "para", options: ["por", "para"], explanation: "Para for recipient — the gift is for you."),
|
||||
GrammarExercise(id: "pp2", prompt: "Choose por or para:", sentence: "Gracias _____ tu ayuda.", correctAnswer: "por", options: ["por", "para"], explanation: "Por for cause/reason — thanks because of your help."),
|
||||
GrammarExercise(id: "pp3", prompt: "Choose por or para:", sentence: "Caminamos _____ el parque.", correctAnswer: "por", options: ["por", "para"], explanation: "Por for movement through a place."),
|
||||
GrammarExercise(id: "pp4", prompt: "Choose por or para:", sentence: "Estudio _____ aprender.", correctAnswer: "para", options: ["por", "para"], explanation: "Para for purpose — in order to learn."),
|
||||
GrammarExercise(id: "pp5", prompt: "Choose por or para:", sentence: "Pagué veinte dólares _____ el libro.", correctAnswer: "por", options: ["por", "para"], explanation: "Por for exchange — paying in exchange for the book."),
|
||||
GrammarExercise(id: "pp6", prompt: "Choose por or para:", sentence: "Salimos _____ Madrid mañana.", correctAnswer: "para", options: ["por", "para"], explanation: "Para for destination — heading toward Madrid."),
|
||||
GrammarExercise(id: "pp7", prompt: "Choose por or para:", sentence: "Estudié _____ dos horas.", correctAnswer: "por", options: ["por", "para"], explanation: "Por for duration — for a period of time."),
|
||||
GrammarExercise(id: "pp8", prompt: "Choose por or para:", sentence: "Necesito el informe _____ el lunes.", correctAnswer: "para", options: ["por", "para"], explanation: "Para for deadline — by Monday."),
|
||||
]
|
||||
|
||||
// MARK: - Preterite vs Imperfect
|
||||
|
||||
private static let preteriteVsImperfectExercises = [
|
||||
GrammarExercise(id: "pi1", prompt: "Choose the correct tense:", sentence: "Ayer _____ una pizza. (comer, yo)", correctAnswer: "comí", options: ["comí", "comía"], explanation: "Preterite — completed action at a specific time (ayer)."),
|
||||
GrammarExercise(id: "pi2", prompt: "Choose the correct tense:", sentence: "Cuando era niño, _____ en el parque. (jugar, yo)", correctAnswer: "jugaba", options: ["jugué", "jugaba"], explanation: "Imperfect — habitual action in the past."),
|
||||
GrammarExercise(id: "pi3", prompt: "Choose the correct tense:", sentence: "Ella _____ a las ocho. (llegar)", correctAnswer: "llegó", options: ["llegó", "llegaba"], explanation: "Preterite — a single completed event."),
|
||||
GrammarExercise(id: "pi4", prompt: "Choose the correct tense:", sentence: "_____ sol y los pájaros cantaban. (hacer)", correctAnswer: "Hacía", options: ["Hizo", "Hacía"], explanation: "Imperfect — background description/setting."),
|
||||
GrammarExercise(id: "pi5", prompt: "Choose the correct tense:", sentence: "De repente, _____ el teléfono. (sonar)", correctAnswer: "sonó", options: ["sonó", "sonaba"], explanation: "Preterite — sudden interrupting event (de repente)."),
|
||||
GrammarExercise(id: "pi6", prompt: "Choose the correct tense:", sentence: "Siempre _____ juntos los domingos. (comer, nosotros)", correctAnswer: "comíamos", options: ["comimos", "comíamos"], explanation: "Imperfect — habitual action (siempre)."),
|
||||
]
|
||||
|
||||
// MARK: - Subjunctive Triggers
|
||||
|
||||
private static let subjunctiveTriggerExercises = [
|
||||
GrammarExercise(id: "st1", prompt: "Subjunctive or indicative?", sentence: "Quiero que _____ a la fiesta. (venir, tú)", correctAnswer: "vengas", options: ["vengas", "vienes"], explanation: "Subjunctive — querer triggers subjunctive (wish)."),
|
||||
GrammarExercise(id: "st2", prompt: "Subjunctive or indicative?", sentence: "Es necesario que _____ más. (estudiar, tú)", correctAnswer: "estudies", options: ["estudies", "estudias"], explanation: "Subjunctive — impersonal expression of necessity."),
|
||||
GrammarExercise(id: "st3", prompt: "Subjunctive or indicative?", sentence: "Sé que ella _____ aquí. (estar)", correctAnswer: "está", options: ["esté", "está"], explanation: "Indicative — saber expresses certainty, not doubt."),
|
||||
GrammarExercise(id: "st4", prompt: "Subjunctive or indicative?", sentence: "Me alegra que _____ aquí. (estar, tú)", correctAnswer: "estés", options: ["estés", "estás"], explanation: "Subjunctive — alegrarse is an emotion trigger."),
|
||||
GrammarExercise(id: "st5", prompt: "Subjunctive or indicative?", sentence: "Dudo que _____ la verdad. (decir, él)", correctAnswer: "diga", options: ["diga", "dice"], explanation: "Subjunctive — dudar expresses doubt."),
|
||||
GrammarExercise(id: "st6", prompt: "Subjunctive or indicative?", sentence: "Es posible que _____ mañana. (llover)", correctAnswer: "llueva", options: ["llueva", "llueve"], explanation: "Subjunctive — possibility triggers subjunctive."),
|
||||
]
|
||||
|
||||
// MARK: - Personal A
|
||||
|
||||
private static let personalAExercises = [
|
||||
GrammarExercise(id: "pa1", prompt: "Is the personal 'a' needed?", sentence: "Veo _____ María.", correctAnswer: "a", options: ["a", "(nothing)"], explanation: "Personal a needed — María is a specific person as direct object."),
|
||||
GrammarExercise(id: "pa2", prompt: "Is the personal 'a' needed?", sentence: "Veo _____ la mesa.", correctAnswer: "(nothing)", options: ["a", "(nothing)"], explanation: "No personal a — la mesa is a thing, not a person."),
|
||||
GrammarExercise(id: "pa3", prompt: "Is the personal 'a' needed?", sentence: "Tengo _____ dos hermanos.", correctAnswer: "(nothing)", options: ["a", "(nothing)"], explanation: "No personal a after tener — important exception."),
|
||||
GrammarExercise(id: "pa4", prompt: "Is the personal 'a' needed?", sentence: "Conozco _____ tu profesor.", correctAnswer: "a", options: ["a", "(nothing)"], explanation: "Personal a needed — specific person as direct object."),
|
||||
GrammarExercise(id: "pa5", prompt: "Is the personal 'a' needed?", sentence: "Busco _____ un doctor.", correctAnswer: "(nothing)", options: ["a", "(nothing)"], explanation: "No personal a — non-specific person (any doctor)."),
|
||||
GrammarExercise(id: "pa6", prompt: "Is the personal 'a' needed?", sentence: "No veo _____ nadie.", correctAnswer: "a", options: ["a", "(nothing)"], explanation: "Personal a with nadie — indefinite pronoun referring to people."),
|
||||
]
|
||||
}
|
||||
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 = 5
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
125
Conjuga/Conjuga/Services/PronunciationService.swift
Normal file
125
Conjuga/Conjuga/Services/PronunciationService.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
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() {
|
||||
// SFSpeechRecognizer.requestAuthorization crashes on simulators
|
||||
// without speech services. Check availability first.
|
||||
guard SFSpeechRecognizer.self != nil else { return }
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
print("[PronunciationService] skipping speech auth on simulator")
|
||||
isAuthorized = false
|
||||
#else
|
||||
print("[PronunciationService] requesting speech authorization...")
|
||||
SFSpeechRecognizer.requestAuthorization { [weak self] status in
|
||||
print("[PronunciationService] authorization status: \(status.rawValue)")
|
||||
Task { @MainActor in
|
||||
self?.isAuthorized = (status == .authorized)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func resolveRecognizerIfNeeded() {
|
||||
guard !recognizerResolved else { return }
|
||||
recognizerResolved = true
|
||||
recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))
|
||||
}
|
||||
|
||||
func startRecording() throws {
|
||||
guard isAuthorized else { return }
|
||||
resolveRecognizerIfNeeded()
|
||||
guard let recognizer, recognizer.isAvailable else { return }
|
||||
|
||||
stopRecording()
|
||||
|
||||
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)
|
||||
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
|
||||
request.append(buffer)
|
||||
}
|
||||
|
||||
audioEngine.prepare()
|
||||
try audioEngine.start()
|
||||
|
||||
transcript = ""
|
||||
isRecording = true
|
||||
|
||||
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
Task { @MainActor in
|
||||
if let result {
|
||||
self?.transcript = result.bestTranscription.formattedString
|
||||
}
|
||||
if error != nil || (result?.isFinal == true) {
|
||||
self?.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 }
|
||||
}
|
||||
}
|
||||
150
Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift
Normal file
150
Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GrammarExerciseView: View {
|
||||
let noteId: String
|
||||
let noteTitle: String
|
||||
|
||||
@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 = GrammarExercise.exercises(for: noteId).shuffled() }
|
||||
}
|
||||
|
||||
@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)
|
||||
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 {
|
||||
try? 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,6 +129,99 @@ struct PracticeView: View {
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Conversation Practice
|
||||
NavigationLink {
|
||||
ChatLibraryView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.green)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Conversation")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Chat with AI in Spanish scenarios")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Listening Practice
|
||||
NavigationLink {
|
||||
ListeningView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "ear.fill")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Listening")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Listen and type, or practice pronunciation")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Cloze Practice
|
||||
NavigationLink {
|
||||
ClozeView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "text.badge.minus")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.indigo)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Cloze Practice")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Fill in the missing word in context")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Stories
|
||||
NavigationLink {
|
||||
StoryLibraryView()
|
||||
@@ -166,6 +259,46 @@ struct PracticeView: View {
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Vocab review
|
||||
NavigationLink {
|
||||
VocabReviewView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "rectangle.stack.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.teal)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Vocab Review")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Review due vocabulary cards")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
|
||||
if dueCount > 0 {
|
||||
Text("\(dueCount)")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.teal, in: Capsule())
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Common tenses focus
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
|
||||
@@ -5,6 +5,7 @@ import FoundationModels
|
||||
struct StoryReaderView: View {
|
||||
let story: Story
|
||||
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@State private var selectedWord: WordAnnotation?
|
||||
@State private var showTranslation = false
|
||||
@State private var lookupCache: [String: WordAnnotation] = [:]
|
||||
@@ -105,7 +106,20 @@ struct StoryReaderView: View {
|
||||
}
|
||||
|
||||
private func lookupWord(_ word: String, inContext sentence: String) {
|
||||
// Show immediately with loading state
|
||||
// Try offline dictionary first
|
||||
if let entry = dictionary.lookup(word) {
|
||||
let annotation = WordAnnotation(
|
||||
word: word,
|
||||
baseForm: entry.baseForm,
|
||||
english: entry.english,
|
||||
partOfSpeech: entry.partOfSpeech
|
||||
)
|
||||
lookupCache[word] = annotation
|
||||
selectedWord = annotation
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to on-device AI lookup
|
||||
selectedWord = WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: "")
|
||||
|
||||
Task {
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user