From a663bc03cd766535d74ed904540a9b65af117d56 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 13 Apr 2026 16:12:36 -0500 Subject: [PATCH] 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) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 58 +++- Conjuga/Conjuga/ConjugaApp.swift | 30 +- Conjuga/Conjuga/Info.plist | 4 + Conjuga/Conjuga/Models/GrammarExercise.swift | 80 +++++ .../Services/ConversationService.swift | 78 +++++ Conjuga/Conjuga/Services/DataLoader.swift | 23 +- .../Conjuga/Services/DictionaryService.swift | 268 +++++++++++++++ .../Services/PronunciationService.swift | 125 +++++++ .../Views/Guide/GrammarExerciseView.swift | 150 ++++++++ .../Views/Guide/GrammarNotesView.swift | 14 + .../Views/Practice/Chat/ChatLibraryView.swift | 126 +++++++ .../Views/Practice/Chat/ChatView.swift | 150 ++++++++ .../Conjuga/Views/Practice/ClozeView.swift | 212 ++++++++++++ .../Views/Practice/ListeningView.swift | 319 ++++++++++++++++++ .../Conjuga/Views/Practice/PracticeView.swift | 133 ++++++++ .../Practice/Stories/StoryReaderView.swift | 16 +- .../Views/Practice/VocabReviewView.swift | 180 ++++++++++ .../Views/Settings/FeatureReferenceView.swift | 252 ++++++++++++++ .../Conjuga/Views/Settings/SettingsView.swift | 6 + .../Sources/SharedModels/Conversation.swift | 48 +++ 20 files changed, 2253 insertions(+), 19 deletions(-) create mode 100644 Conjuga/Conjuga/Models/GrammarExercise.swift create mode 100644 Conjuga/Conjuga/Services/ConversationService.swift create mode 100644 Conjuga/Conjuga/Services/DictionaryService.swift create mode 100644 Conjuga/Conjuga/Services/PronunciationService.swift create mode 100644 Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/ClozeView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/ListeningView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/VocabReviewView.swift create mode 100644 Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift create mode 100644 Conjuga/SharedModels/Sources/SharedModels/Conversation.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 9bc366f..e880ee1 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -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 = ""; }; 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = ""; }; E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = ""; }; + A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = ""; }; + D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = ""; }; + A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = ""; }; + 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = ""; }; + 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = ""; }; + D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = ""; }; + 02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = ""; }; + E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = ""; }; + 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = ""; }; + FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -234,6 +256,7 @@ isa = PBXGroup; children = ( BCCC95A95581458E068E0484 /* SettingsView.swift */, + 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */, ); path = Settings; sourceTree = ""; @@ -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 = ""; }; @@ -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 = ""; }; @@ -353,9 +384,19 @@ children = ( 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */, 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */, - ); + 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */, +); path = Guide; sourceTree = ""; + }; + DFD75E32A53845A693D98F48 /* Chat */ = { + isa = PBXGroup; + children = ( + 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */, + FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */, + ); + path = Chat; + sourceTree = ""; }; 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; }; diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index 4e47b2d..92b8613 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -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) } + } diff --git a/Conjuga/Conjuga/Info.plist b/Conjuga/Conjuga/Info.plist index ee6dff3..9a8967e 100644 --- a/Conjuga/Conjuga/Info.plist +++ b/Conjuga/Conjuga/Info.plist @@ -24,6 +24,10 @@ public.app-category.education UILaunchScreen + NSSpeechRecognitionUsageDescription + Conjuga uses speech recognition to check your Spanish pronunciation and transcribe what you say during listening exercises. + NSMicrophoneUsageDescription + Conjuga needs microphone access to record your voice for pronunciation practice. UIBackgroundModes fetch diff --git a/Conjuga/Conjuga/Models/GrammarExercise.swift b/Conjuga/Conjuga/Models/GrammarExercise.swift new file mode 100644 index 0000000..46abff8 --- /dev/null +++ b/Conjuga/Conjuga/Models/GrammarExercise.swift @@ -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."), + ] +} diff --git a/Conjuga/Conjuga/Services/ConversationService.swift b/Conjuga/Conjuga/Services/ConversationService.swift new file mode 100644 index 0000000..a00366b --- /dev/null +++ b/Conjuga/Conjuga/Services/ConversationService.swift @@ -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 + } +} diff --git a/Conjuga/Conjuga/Services/DataLoader.swift b/Conjuga/Conjuga/Services/DataLoader.swift index 9b9195b..c8705d0 100644 --- a/Conjuga/Conjuga/Services/DataLoader.swift +++ b/Conjuga/Conjuga/Services/DataLoader.swift @@ -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())) ?? 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( diff --git a/Conjuga/Conjuga/Services/DictionaryService.swift b/Conjuga/Conjuga/Services/DictionaryService.swift new file mode 100644 index 0000000..d2c1541 --- /dev/null +++ b/Conjuga/Conjuga/Services/DictionaryService.swift @@ -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() + let verbs = (try? context.fetch(verbDescriptor)) ?? [] + let verbMap = Dictionary(uniqueKeysWithValues: verbs.map { ($0.id, $0) }) + + let formDescriptor = FetchDescriptor() + 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 + ) + } + } +} diff --git a/Conjuga/Conjuga/Services/PronunciationService.swift b/Conjuga/Conjuga/Services/PronunciationService.swift new file mode 100644 index 0000000..1c42b3e --- /dev/null +++ b/Conjuga/Conjuga/Services/PronunciationService.swift @@ -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 } + } +} diff --git a/Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift b/Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift new file mode 100644 index 0000000..204c373 --- /dev/null +++ b/Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift @@ -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 + } +} diff --git a/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift b/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift index f08a172..7ebe2d8 100644 --- a/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift +++ b/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift @@ -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) diff --git a/Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift b/Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift new file mode 100644 index 0000000..bf246b6 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift @@ -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( + 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() } + } + } + } + } +} diff --git a/Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift b/Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift new file mode 100644 index 0000000..ca27ab9 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift @@ -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) + } +} diff --git a/Conjuga/Conjuga/Views/Practice/ClozeView.swift b/Conjuga/Conjuga/Views/Practice/ClozeView.swift new file mode 100644 index 0000000..ffe1017 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/ClozeView.swift @@ -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() + 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 + } +} diff --git a/Conjuga/Conjuga/Views/Practice/ListeningView.swift b/Conjuga/Conjuga/Views/Practice/ListeningView.swift new file mode 100644 index 0000000..b3939f6 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/ListeningView.swift @@ -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() + 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 + } +} diff --git a/Conjuga/Conjuga/Views/Practice/PracticeView.swift b/Conjuga/Conjuga/Views/Practice/PracticeView.swift index 3dcfd2b..fa1ef98 100644 --- a/Conjuga/Conjuga/Views/Practice/PracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/PracticeView.swift @@ -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 diff --git a/Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift b/Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift index 1433fad..b3e878a 100644 --- a/Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift +++ b/Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift @@ -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 { diff --git a/Conjuga/Conjuga/Views/Practice/VocabReviewView.swift b/Conjuga/Conjuga/Views/Practice/VocabReviewView.swift new file mode 100644 index 0000000..7340004 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/VocabReviewView.swift @@ -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( + predicate: #Predicate { $0.dueDate <= now }, + sortBy: [SortDescriptor(\CourseReviewCard.dueDate)] + ) + dueCards = (try? cloudContext.fetch(descriptor)) ?? [] + } + + static func dueCount(context: ModelContext) -> Int { + let now = Date() + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.dueDate <= now } + ) + return (try? context.fetchCount(descriptor)) ?? 0 + } +} + +private extension Collection { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift b/Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift new file mode 100644 index 0000000..d90c223 --- /dev/null +++ b/Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift @@ -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) + } +} diff --git a/Conjuga/Conjuga/Views/Settings/SettingsView.swift b/Conjuga/Conjuga/Views/Settings/SettingsView.swift index 84ba361..56fcffc 100644 --- a/Conjuga/Conjuga/Views/Settings/SettingsView.swift +++ b/Conjuga/Conjuga/Views/Settings/SettingsView.swift @@ -75,6 +75,12 @@ struct SettingsView: View { } } + Section("Reference") { + NavigationLink("How Features Work") { + FeatureReferenceView() + } + } + Section("About") { LabeledContent("Version", value: "1.0.0") } diff --git a/Conjuga/SharedModels/Sources/SharedModels/Conversation.swift b/Conjuga/SharedModels/Sources/SharedModels/Conversation.swift new file mode 100644 index 0000000..2923e23 --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/Conversation.swift @@ -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 + } + } +} -- 2.49.1