From 451866e98824463bb00229a70310cad37627cffa Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 13 Apr 2026 11:31:58 -0500 Subject: [PATCH] Add AI-generated short stories with tappable words and comprehension quiz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate one-paragraph Spanish stories on-device using Foundation Models, matched to user's level and enabled tenses. Every word is tappable — pre-annotated words show instantly, others get a quick on-device AI lookup with caching. English translation hidden by default behind a toggle. Comprehension quiz with 3 multiple-choice questions. Stories saved to cloud container for sync and persistence across resets. Closes #9 Co-Authored-By: Claude Opus 4.6 (1M context) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 24 ++ Conjuga/Conjuga/ConjugaApp.swift | 6 +- Conjuga/Conjuga/Services/StoryGenerator.swift | 97 ++++++ .../Conjuga/Views/Practice/PracticeView.swift | 31 ++ .../Practice/Stories/StoryLibraryView.swift | 134 ++++++++ .../Practice/Stories/StoryQuizView.swift | 152 +++++++++ .../Practice/Stories/StoryReaderView.swift | 319 ++++++++++++++++++ .../Sources/SharedModels/Story.swift | 67 ++++ 8 files changed, 827 insertions(+), 3 deletions(-) create mode 100644 Conjuga/Conjuga/Services/StoryGenerator.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift create mode 100644 Conjuga/SharedModels/Sources/SharedModels/Story.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 7cbe7fe..9bc366f 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -79,6 +79,10 @@ F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; }; FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; }; DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; }; + 8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327659ABFD524514B6D2D505 /* StoryGenerator.swift */; }; + 4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; }; + 8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; }; + 12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -182,6 +186,10 @@ E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = ""; }; 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = ""; }; + 327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = ""; }; + 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -248,6 +256,7 @@ 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */, 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */, 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */, + 327659ABFD524514B6D2D505 /* StoryGenerator.swift */, 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */, 777C696A841803D5B775B678 /* ReferenceStore.swift */, CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */, @@ -334,6 +343,7 @@ 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */, 10C16AA6022E4742898745CE /* TypingView.swift */, 895E547BEFB5D0FBF676BE33 /* Lyrics */, + 8A1DED0596E04DDE9536A9A9 /* Stories */, ); path = Practice; sourceTree = ""; @@ -347,6 +357,16 @@ path = Guide; sourceTree = ""; }; + 8A1DED0596E04DDE9536A9A9 /* Stories */ = { + isa = PBXGroup; + children = ( + 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */, + 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */, + E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */, + ); + path = Stories; + sourceTree = ""; + }; 895E547BEFB5D0FBF676BE33 /* Lyrics */ = { isa = PBXGroup; children = ( @@ -593,6 +613,10 @@ 968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */, + 8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */, + 4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */, + 8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */, + 12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index 683f680..4e47b2d 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, + TestResult.self, DailyLog.self, SavedSong.self, Story.self, configurations: configuration ) }() @@ -67,13 +67,13 @@ struct ConjugaApp: App { "cloud", schema: Schema([ ReviewCard.self, CourseReviewCard.self, UserProgress.self, - TestResult.self, DailyLog.self, SavedSong.self, + TestResult.self, DailyLog.self, SavedSong.self, Story.self, ]), cloudKitDatabase: .private("iCloud.com.conjuga.app") ) cloudContainer = try ModelContainer( for: ReviewCard.self, CourseReviewCard.self, UserProgress.self, - TestResult.self, DailyLog.self, SavedSong.self, + TestResult.self, DailyLog.self, SavedSong.self, Story.self, configurations: cloudConfig ) } catch { diff --git a/Conjuga/Conjuga/Services/StoryGenerator.swift b/Conjuga/Conjuga/Services/StoryGenerator.swift new file mode 100644 index 0000000..4abd94b --- /dev/null +++ b/Conjuga/Conjuga/Services/StoryGenerator.swift @@ -0,0 +1,97 @@ +import Foundation +import FoundationModels +import SharedModels +import SwiftData + +@MainActor +struct StoryGenerator { + + // MARK: - Generable Types + + @Generable + struct GeneratedStory { + @Guide(description: "A short creative title for the story in Spanish, 3-6 words") + var title: String + + @Guide(description: "A one-paragraph story in Spanish, 5-8 sentences long, using vocabulary and grammar appropriate for the student level") + var bodyES: String + + @Guide(description: "An accurate English translation of bodyES") + var bodyEN: String + + @Guide(description: "Every word from the story annotated with its base form, English meaning, and part of speech. Include articles, prepositions, and all other words.") + var words: [GeneratedAnnotation] + + @Guide(description: "3 reading comprehension questions about the story, each with 4 answer options in Spanish", .count(3)) + var questions: [GeneratedQuestion] + } + + @Generable + struct GeneratedAnnotation { + @Guide(description: "The exact word as it appears in the story") + var word: String + @Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)") + var baseForm: String + @Guide(description: "English translation of the word") + var english: String + @Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, or other") + var partOfSpeech: String + } + + @Generable + struct GeneratedQuestion { + @Guide(description: "A comprehension question about the story in Spanish") + var question: String + @Guide(description: "4 answer options in Spanish", .count(4)) + var options: [String] + @Guide(description: "Index of the correct answer (0-3)", .range(0...3)) + var correctIndex: Int + } + + // MARK: - Generation + + static func generate(level: String, tenses: [String]) async throws -> Story { + let tenseNames = tenses.isEmpty + ? "present, preterite, imperfect, and future" + : tenses.joined(separator: ", ") + + let session = LanguageModelSession(instructions: """ + You are a Spanish language teacher creating a short reading exercise. + The student's level is: \(level). + Focus on these verb tenses: \(tenseNames). + Write naturally but keep vocabulary appropriate for the level. + Use common, everyday scenarios (shopping, travel, family, school, work, food). + The story should be exactly one paragraph of 5-8 sentences. + """) + + let response = try await session.respond( + to: "Create a short Spanish story for reading practice.", + generating: GeneratedStory.self + ) + + let story = response.content + + let annotations = story.words.map { + WordAnnotation(word: $0.word, baseForm: $0.baseForm, english: $0.english, partOfSpeech: $0.partOfSpeech) + } + let questions = story.questions.map { + QuizQuestion(question: $0.question, options: $0.options, correctIndex: $0.correctIndex) + } + + let annotationsJSON = (try? String(data: JSONEncoder().encode(annotations), encoding: .utf8)) ?? "[]" + let questionsJSON = (try? String(data: JSONEncoder().encode(questions), encoding: .utf8)) ?? "[]" + + return Story( + title: story.title, + bodyES: story.bodyES, + bodyEN: story.bodyEN, + level: level, + wordAnnotations: annotationsJSON, + quizQuestions: questionsJSON + ) + } + + static var isAvailable: Bool { + SystemLanguageModel.default.availability == .available + } +} diff --git a/Conjuga/Conjuga/Views/Practice/PracticeView.swift b/Conjuga/Conjuga/Views/Practice/PracticeView.swift index 0193a54..3dcfd2b 100644 --- a/Conjuga/Conjuga/Views/Practice/PracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/PracticeView.swift @@ -129,6 +129,37 @@ struct PracticeView: View { .glassEffect(in: RoundedRectangle(cornerRadius: 14)) .padding(.horizontal) + // Stories + NavigationLink { + StoryLibraryView() + } label: { + HStack(spacing: 14) { + Image(systemName: "book.fill") + .font(.title3) + .frame(width: 36) + .foregroundStyle(.teal) + + VStack(alignment: .leading, spacing: 2) { + Text("Stories") + .font(.subheadline.weight(.semibold)) + Text("Read AI-generated Spanish stories") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + .tint(.primary) + .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal) + // Quick Actions VStack(spacing: 12) { Text("Quick Actions") diff --git a/Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift b/Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift new file mode 100644 index 0000000..76cbc96 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift @@ -0,0 +1,134 @@ +import SwiftUI +import SharedModels +import SwiftData + +struct StoryLibraryView: View { + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + @State private var stories: [Story] = [] + @State private var isGenerating = false + @State private var errorMessage: String? + + private var cloudModelContext: ModelContext { cloudModelContextProvider() } + + var body: some View { + Group { + if stories.isEmpty && !isGenerating { + ContentUnavailableView( + "No Stories Yet", + systemImage: "book.closed", + description: Text("Tap + to generate a Spanish story with AI.") + ) + } else { + List { + if isGenerating { + HStack(spacing: 12) { + ProgressView() + Text("Generating story...") + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + ForEach(stories) { story in + NavigationLink { + StoryReaderView(story: story) + } label: { + StoryRowView(story: story) + } + } + .onDelete(perform: deleteStories) + } + } + } + .navigationTitle("Stories") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + generateStory() + } label: { + Image(systemName: "plus") + } + .disabled(isGenerating || !StoryGenerator.isAvailable) + } + } + .alert("Error", isPresented: .init( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + .onAppear(perform: loadStories) + } + + private func loadStories() { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\Story.createdDate, order: .reverse)] + ) + stories = (try? cloudModelContext.fetch(descriptor)) ?? [] + } + + private func generateStory() { + guard !isGenerating else { return } + + guard StoryGenerator.isAvailable else { + errorMessage = "Apple Intelligence is not available on this device. Stories require an iPhone 15 Pro or later with Apple Intelligence enabled." + return + } + + isGenerating = true + let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) + let level = progress.selectedLevel + let tenses = progress.enabledTenseIDs + + Task { + do { + let story = try await StoryGenerator.generate(level: level, tenses: tenses) + cloudModelContext.insert(story) + try? cloudModelContext.save() + loadStories() + } catch { + errorMessage = "Failed to generate story: \(error.localizedDescription)" + } + isGenerating = false + } + } + + private func deleteStories(at offsets: IndexSet) { + for index in offsets { + cloudModelContext.delete(stories[index]) + } + try? cloudModelContext.save() + loadStories() + } +} + +// MARK: - Story Row + +private struct StoryRowView: View { + let story: Story + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(story.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + + HStack(spacing: 8) { + Text(story.level.capitalized) + .font(.caption2.weight(.medium)) + .foregroundStyle(.teal) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.teal.opacity(0.12), in: Capsule()) + + Text(story.createdDate.formatted(date: .abbreviated, time: .omitted)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } +} diff --git a/Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift b/Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift new file mode 100644 index 0000000..b0e4976 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift @@ -0,0 +1,152 @@ +import SwiftUI +import SharedModels + +struct StoryQuizView: View { + let story: Story + + @State private var currentIndex = 0 + @State private var selectedOption: Int? + @State private var correctCount = 0 + @State private var isFinished = false + + private var questions: [QuizQuestion] { story.decodedQuestions } + + var body: some View { + VStack(spacing: 24) { + if isFinished { + finishedView + } else if let question = questions[safe: currentIndex] { + questionView(question) + } + } + .padding() + .adaptiveContainer(maxWidth: 600) + .navigationTitle("Comprehension Quiz") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Question View + + @ViewBuilder + private func questionView(_ question: QuizQuestion) -> some View { + VStack(spacing: 20) { + // Progress + Text("Question \(currentIndex + 1) of \(questions.count)") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + + // Question + Text(question.question) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.center) + .padding() + .frame(maxWidth: .infinity) + .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) + + // Options + VStack(spacing: 10) { + ForEach(Array(question.options.enumerated()), id: \.offset) { index, option in + Button { + guard selectedOption == nil else { return } + selectedOption = index + if index == question.correctIndex { + correctCount += 1 + } + } label: { + HStack { + Text(option) + .font(.body) + .multilineTextAlignment(.leading) + Spacer() + if let selected = selectedOption { + if index == question.correctIndex { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } else if index == selected { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(optionBackground(index: index, correct: question.correctIndex), in: RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + } + + Spacer() + + // Next button + if selectedOption != nil { + Button { + if currentIndex + 1 < questions.count { + currentIndex += 1 + selectedOption = nil + } else { + withAnimation { isFinished = true } + } + } label: { + Text(currentIndex + 1 < questions.count ? "Next Question" : "See Results") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .tint(.teal) + } + } + } + + // MARK: - Finished View + + private var finishedView: some View { + VStack(spacing: 20) { + Spacer() + + Image(systemName: correctCount == questions.count ? "star.fill" : "checkmark.circle") + .font(.system(size: 60)) + .foregroundStyle(correctCount == questions.count ? .yellow : .teal) + + Text("\(correctCount) / \(questions.count)") + .font(.system(size: 48, weight: .bold).monospacedDigit()) + + Text(scoreMessage) + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Spacer() + } + } + + private var scoreMessage: String { + switch correctCount { + case questions.count: return "Perfect score!" + case _ where correctCount > questions.count / 2: return "Good job! Keep reading." + default: return "Try re-reading the story and quiz again." + } + } + + // MARK: - Helpers + + private func optionBackground(index: Int, correct: Int) -> some ShapeStyle { + guard let selected = selectedOption else { + return AnyShapeStyle(.fill.quaternary) + } + if index == correct { + return AnyShapeStyle(.green.opacity(0.15)) + } + if index == selected { + return AnyShapeStyle(.red.opacity(0.15)) + } + return AnyShapeStyle(.fill.quaternary) + } +} + +private extension Collection { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift b/Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift new file mode 100644 index 0000000..1433fad --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift @@ -0,0 +1,319 @@ +import SwiftUI +import SharedModels +import FoundationModels + +struct StoryReaderView: View { + let story: Story + + @State private var selectedWord: WordAnnotation? + @State private var showTranslation = false + @State private var lookupCache: [String: WordAnnotation] = [:] + + private var annotations: [WordAnnotation] { story.decodedAnnotations } + private var annotationMap: [String: WordAnnotation] { + Dictionary(annotations.map { (cleanWord($0.word), $0) }, uniquingKeysWith: { first, _ in first }) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Title + Text(story.title) + .font(.title2.bold()) + + // Level badge + Text(story.level.capitalized) + .font(.caption2.weight(.medium)) + .foregroundStyle(.teal) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.teal.opacity(0.12), in: Capsule()) + + Divider() + + // Tappable Spanish text + tappableText + .padding() + .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) + + // Translation toggle + VStack(alignment: .leading, spacing: 8) { + Button { + withAnimation { showTranslation.toggle() } + } label: { + Label( + showTranslation ? "Hide Translation" : "Show Translation", + systemImage: showTranslation ? "eye.slash" : "eye" + ) + .font(.subheadline.weight(.medium)) + } + .tint(.secondary) + + if showTranslation { + Text(story.bodyEN) + .font(.body) + .foregroundStyle(.secondary) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + // Quiz button + if !story.decodedQuestions.isEmpty { + NavigationLink { + StoryQuizView(story: story) + } label: { + Label("Take Comprehension Quiz", systemImage: "questionmark.circle") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .tint(.teal) + } + } + .padding() + .adaptiveContainer(maxWidth: 800) + } + .navigationTitle("Story") + .navigationBarTitleDisplayMode(.inline) + .sheet(item: $selectedWord) { word in + WordDetailSheet(word: word) + .presentationDetents([.height(200)]) + } + } + + // MARK: - Tappable Text + + private var tappableText: some View { + let words = story.bodyES.components(separatedBy: " ") + let map = annotationMap + let cache = lookupCache + let context = story.bodyES + + return WrappingHStack(words: words) { word in + WordButton(word: word, map: map, cache: cache) { ann in + if ann.english.isEmpty { + lookupWord(ann.word, inContext: context) + } else { + selectedWord = ann + } + } + } + } + + private func lookupWord(_ word: String, inContext sentence: String) { + // Show immediately with loading state + selectedWord = WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: "") + + Task { + do { + let annotation = try await WordLookup.lookup(word: word, inContext: sentence) + lookupCache[word] = annotation + selectedWord = annotation + } catch { + selectedWord = WordAnnotation(word: word, baseForm: word, english: "Lookup unavailable", partOfSpeech: "") + } + } + } + + private func cleanWord(_ word: String) -> String { + word.lowercased() + .trimmingCharacters(in: .punctuationCharacters) + .trimmingCharacters(in: .whitespaces) + } +} + +// MARK: - Word Button + +private struct WordButton: View { + let word: String + let map: [String: WordAnnotation] + let cache: [String: WordAnnotation] + let onTap: (WordAnnotation) -> Void + + private var cleaned: String { + word.lowercased() + .trimmingCharacters(in: .punctuationCharacters) + .trimmingCharacters(in: .whitespaces) + } + + private var resolvedAnnotation: WordAnnotation { + map[cleaned] ?? cache[cleaned] ?? WordAnnotation(word: cleaned, baseForm: cleaned, english: "", partOfSpeech: "") + } + + var body: some View { + Button { + onTap(resolvedAnnotation) + } label: { + Text(word + " ") + .font(.body) + .foregroundStyle(.primary) + .underline(true, color: .teal.opacity(0.3)) + } + .buttonStyle(.plain) + } +} + +// MARK: - Wrapping HStack + +private struct WrappingHStack: View { + let words: [String] + let content: (String) -> Content + + var body: some View { + FlowLayout(spacing: 0) { + ForEach(Array(words.enumerated()), id: \.offset) { _, word in + content(word) + } + } + .accessibilityElement(children: .combine) + } +} + +private struct FlowLayout: Layout { + var spacing: CGFloat = 0 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = computeRows(proposal: proposal, subviews: subviews) + var height: CGFloat = 0 + for row in rows { + height += row.map { $0.height }.max() ?? 0 + } + height += CGFloat(max(0, rows.count - 1)) * spacing + return CGSize(width: proposal.width ?? 0, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let rows = computeRows(proposal: proposal, subviews: subviews) + var y = bounds.minY + var subviewIndex = 0 + for row in rows { + var x = bounds.minX + let rowHeight = row.map { $0.height }.max() ?? 0 + for size in row { + subviews[subviewIndex].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + subviewIndex += 1 + } + y += rowHeight + spacing + } + } + + private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] { + let maxWidth = proposal.width ?? .infinity + var rows: [[CGSize]] = [[]] + var currentWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if currentWidth + size.width > maxWidth && !rows[rows.count - 1].isEmpty { + rows.append([]) + currentWidth = 0 + } + rows[rows.count - 1].append(size) + currentWidth += size.width + } + return rows + } +} + +// MARK: - Word Detail Sheet + +private struct WordDetailSheet: View { + let word: WordAnnotation + + var body: some View { + VStack(spacing: 16) { + HStack { + Text(word.word) + .font(.title2.bold()) + Spacer() + if !word.partOfSpeech.isEmpty { + Text(word.partOfSpeech) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.fill.tertiary, in: Capsule()) + } + } + + Divider() + + if word.english == "Looking up..." { + HStack(spacing: 8) { + ProgressView() + Text("Looking up word...") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } else { + VStack(alignment: .leading, spacing: 8) { + if !word.baseForm.isEmpty && word.baseForm != word.word { + HStack { + Text("Base form:") + .font(.subheadline) + .foregroundStyle(.secondary) + Text(word.baseForm) + .font(.subheadline.weight(.semibold)) + .italic() + } + } + + if !word.english.isEmpty { + HStack { + Text("English:") + .font(.subheadline) + .foregroundStyle(.secondary) + Text(word.english) + .font(.subheadline.weight(.semibold)) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + } + .padding() + } +} + +// MARK: - On-Demand Word Lookup + +@MainActor +private enum WordLookup { + @Generable + struct WordInfo { + @Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)") + var baseForm: String + @Guide(description: "English translation") + var english: String + @Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, conjunction, article, pronoun, or other") + var partOfSpeech: String + } + + static func lookup(word: String, inContext sentence: String) async throws -> WordAnnotation { + let session = LanguageModelSession(instructions: """ + You are a Spanish dictionary. Given a word and the sentence it appears in, \ + provide its base form, English translation, and part of speech. + """) + + let response = try await session.respond( + to: "Word: \"\(word)\" in sentence: \"\(sentence)\"", + generating: WordInfo.self + ) + + let info = response.content + return WordAnnotation( + word: word, + baseForm: info.baseForm, + english: info.english, + partOfSpeech: info.partOfSpeech + ) + } +} diff --git a/Conjuga/SharedModels/Sources/SharedModels/Story.swift b/Conjuga/SharedModels/Sources/SharedModels/Story.swift new file mode 100644 index 0000000..f14e140 --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/Story.swift @@ -0,0 +1,67 @@ +import SwiftData +import Foundation + +@Model +public final class Story { + public var id: String = "" + public var title: String = "" + public var bodyES: String = "" + public var bodyEN: String = "" + public var level: String = "" + public var wordAnnotations: String = "[]" + public var quizQuestions: String = "[]" + public var createdDate: Date = Date() + + public init(title: String, bodyES: String, bodyEN: String, level: String, wordAnnotations: String, quizQuestions: String) { + self.id = UUID().uuidString + self.title = title + self.bodyES = bodyES + self.bodyEN = bodyEN + self.level = level + self.wordAnnotations = wordAnnotations + self.quizQuestions = quizQuestions + self.createdDate = Date() + } +} + +// MARK: - JSON Helpers + +public struct WordAnnotation: Codable, Identifiable, Hashable { + public var id: String { word } + public let word: String + public let baseForm: String + public let english: String + public let partOfSpeech: String + + public init(word: String, baseForm: String, english: String, partOfSpeech: String) { + self.word = word + self.baseForm = baseForm + self.english = english + self.partOfSpeech = partOfSpeech + } +} + +public struct QuizQuestion: Codable, Identifiable, Hashable { + public var id: String { question } + public let question: String + public let options: [String] + public let correctIndex: Int + + public init(question: String, options: [String], correctIndex: Int) { + self.question = question + self.options = options + self.correctIndex = correctIndex + } +} + +extension Story { + public var decodedAnnotations: [WordAnnotation] { + guard let data = wordAnnotations.data(using: .utf8) else { return [] } + return (try? JSONDecoder().decode([WordAnnotation].self, from: data)) ?? [] + } + + public var decodedQuestions: [QuizQuestion] { + guard let data = quizQuestions.data(using: .utf8) else { return [] } + return (try? JSONDecoder().decode([QuizQuestion].self, from: data)) ?? [] + } +}