Add 6 new practice features, offline dictionary, and feature reference #12

Merged
admin merged 1 commits from newStuff into main 2026-04-13 16:13:15 -05:00
20 changed files with 2253 additions and 19 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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."),
]
}

View File

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

View File

@@ -3,6 +3,21 @@ import SharedModels
import Foundation
actor DataLoader {
static let courseDataVersion = 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(

View File

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

View File

@@ -0,0 +1,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 }
}
}

View 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,319 @@
import SwiftUI
import SharedModels
import SwiftData
struct ListeningView: View {
@Environment(\.modelContext) private var localContext
@State private var pronunciation = PronunciationService()
@State private var speechService = SpeechService()
@State private var sentences: [(spanish: String, english: String)] = []
@State private var currentIndex = 0
@State private var userInput = ""
@State private var isRevealed = false
@State private var score: Double?
@State private var wordMatches: [PronunciationService.WordMatch] = []
@State private var mode: ListeningMode = .listenType
@State private var correctCount = 0
@State private var isFinished = false
enum ListeningMode: String, CaseIterable {
case listenType = "Listen & Type"
case speakCheck = "Pronunciation"
}
var body: some View {
VStack(spacing: 20) {
if isFinished {
finishedView
} else if sentences.isEmpty {
ContentUnavailableView("No sentences available", systemImage: "waveform", description: Text("Complete some course decks first."))
} else {
exerciseView
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Listening Practice")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
print("[ListeningView] onAppear — loading sentences")
loadSentences()
print("[ListeningView] loaded \(sentences.count) sentences, requesting auth")
Task {
pronunciation.requestAuthorization()
}
}
}
// MARK: - Exercise
@ViewBuilder
private var exerciseView: some View {
VStack(spacing: 20) {
// Mode picker
Picker("Mode", selection: $mode) {
ForEach(ListeningMode.allCases, id: \.self) { m in
Text(m.rawValue).tag(m)
}
}
.pickerStyle(.segmented)
Text("\(currentIndex + 1) / \(sentences.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
if mode == .listenType {
listenAndTypeView
} else {
pronunciationCheckView
}
}
}
// MARK: - Listen & Type
@ViewBuilder
private var listenAndTypeView: some View {
let sentence = sentences[currentIndex]
VStack(spacing: 16) {
// Play button
Button {
speechService.speak(sentence.spanish)
} label: {
Label("Play", systemImage: "speaker.wave.2.fill")
.font(.headline)
.padding()
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.blue)
// User types what they heard
TextField("Type what you hear...", text: $userInput)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if isRevealed {
// Show correct answer
VStack(alignment: .leading, spacing: 8) {
Text("Correct:")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(sentence.spanish)
.font(.body.weight(.medium))
Text(sentence.english)
.font(.callout)
.foregroundStyle(.secondary)
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
Text("Score: \(Int(result.score * 100))%")
.font(.headline)
.foregroundStyle(result.score >= 0.8 ? .green : result.score >= 0.5 ? .orange : .red)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
nextButton
} else {
Button {
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
if result.score >= 0.7 { correctCount += 1 }
withAnimation { isRevealed = true }
} label: {
Text("Check")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
.disabled(userInput.isEmpty)
}
}
}
// MARK: - Pronunciation Check
@ViewBuilder
private var pronunciationCheckView: some View {
let sentence = sentences[currentIndex]
VStack(spacing: 16) {
// Show the sentence to read
Text(sentence.spanish)
.font(.title3.weight(.medium))
.multilineTextAlignment(.center)
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
Text(sentence.english)
.font(.callout)
.foregroundStyle(.secondary)
// Mic button
Button {
if pronunciation.isRecording {
pronunciation.stopRecording()
// Score after stopping
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: pronunciation.transcript)
score = result.score
wordMatches = result.matches
if result.score >= 0.7 { correctCount += 1 }
withAnimation { isRevealed = true }
} else {
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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