Add AI-generated short stories with tappable words #11
@@ -79,6 +79,10 @@
|
|||||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
||||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
|
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
|
||||||
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -182,6 +186,10 @@
|
|||||||
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
|
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
|
||||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
||||||
|
327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
|
||||||
|
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
|
||||||
|
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
|
||||||
|
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -248,6 +256,7 @@
|
|||||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||||
|
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
|
||||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
|
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
|
||||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||||
@@ -334,6 +343,7 @@
|
|||||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||||
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||||
|
8A1DED0596E04DDE9536A9A9 /* Stories */,
|
||||||
);
|
);
|
||||||
path = Practice;
|
path = Practice;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -347,6 +357,16 @@
|
|||||||
path = Guide;
|
path = Guide;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
8A1DED0596E04DDE9536A9A9 /* Stories */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */,
|
||||||
|
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */,
|
||||||
|
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */,
|
||||||
|
);
|
||||||
|
path = Stories;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
|
895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -593,6 +613,10 @@
|
|||||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||||
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ private enum CloudPreviewContainer {
|
|||||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||||
return try! ModelContainer(
|
return try! ModelContainer(
|
||||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||||
TestResult.self, DailyLog.self, SavedSong.self,
|
TestResult.self, DailyLog.self, SavedSong.self, Story.self,
|
||||||
configurations: configuration
|
configurations: configuration
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
@@ -67,13 +67,13 @@ struct ConjugaApp: App {
|
|||||||
"cloud",
|
"cloud",
|
||||||
schema: Schema([
|
schema: Schema([
|
||||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
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")
|
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||||
)
|
)
|
||||||
cloudContainer = try ModelContainer(
|
cloudContainer = try ModelContainer(
|
||||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||||
TestResult.self, DailyLog.self, SavedSong.self,
|
TestResult.self, DailyLog.self, SavedSong.self, Story.self,
|
||||||
configurations: cloudConfig
|
configurations: cloudConfig
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
97
Conjuga/Conjuga/Services/StoryGenerator.swift
Normal file
97
Conjuga/Conjuga/Services/StoryGenerator.swift
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct StoryGenerator {
|
||||||
|
|
||||||
|
// MARK: - Generable Types
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
struct GeneratedStory {
|
||||||
|
@Guide(description: "A short creative title for the story in Spanish, 3-6 words")
|
||||||
|
var title: String
|
||||||
|
|
||||||
|
@Guide(description: "A one-paragraph story in Spanish, 5-8 sentences long, using vocabulary and grammar appropriate for the student level")
|
||||||
|
var bodyES: String
|
||||||
|
|
||||||
|
@Guide(description: "An accurate English translation of bodyES")
|
||||||
|
var bodyEN: String
|
||||||
|
|
||||||
|
@Guide(description: "Every word from the story annotated with its base form, English meaning, and part of speech. Include articles, prepositions, and all other words.")
|
||||||
|
var words: [GeneratedAnnotation]
|
||||||
|
|
||||||
|
@Guide(description: "3 reading comprehension questions about the story, each with 4 answer options in Spanish", .count(3))
|
||||||
|
var questions: [GeneratedQuestion]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
struct GeneratedAnnotation {
|
||||||
|
@Guide(description: "The exact word as it appears in the story")
|
||||||
|
var word: String
|
||||||
|
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
|
||||||
|
var baseForm: String
|
||||||
|
@Guide(description: "English translation of the word")
|
||||||
|
var english: String
|
||||||
|
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, or other")
|
||||||
|
var partOfSpeech: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
struct GeneratedQuestion {
|
||||||
|
@Guide(description: "A comprehension question about the story in Spanish")
|
||||||
|
var question: String
|
||||||
|
@Guide(description: "4 answer options in Spanish", .count(4))
|
||||||
|
var options: [String]
|
||||||
|
@Guide(description: "Index of the correct answer (0-3)", .range(0...3))
|
||||||
|
var correctIndex: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generation
|
||||||
|
|
||||||
|
static func generate(level: String, tenses: [String]) async throws -> Story {
|
||||||
|
let tenseNames = tenses.isEmpty
|
||||||
|
? "present, preterite, imperfect, and future"
|
||||||
|
: tenses.joined(separator: ", ")
|
||||||
|
|
||||||
|
let session = LanguageModelSession(instructions: """
|
||||||
|
You are a Spanish language teacher creating a short reading exercise.
|
||||||
|
The student's level is: \(level).
|
||||||
|
Focus on these verb tenses: \(tenseNames).
|
||||||
|
Write naturally but keep vocabulary appropriate for the level.
|
||||||
|
Use common, everyday scenarios (shopping, travel, family, school, work, food).
|
||||||
|
The story should be exactly one paragraph of 5-8 sentences.
|
||||||
|
""")
|
||||||
|
|
||||||
|
let response = try await session.respond(
|
||||||
|
to: "Create a short Spanish story for reading practice.",
|
||||||
|
generating: GeneratedStory.self
|
||||||
|
)
|
||||||
|
|
||||||
|
let story = response.content
|
||||||
|
|
||||||
|
let annotations = story.words.map {
|
||||||
|
WordAnnotation(word: $0.word, baseForm: $0.baseForm, english: $0.english, partOfSpeech: $0.partOfSpeech)
|
||||||
|
}
|
||||||
|
let questions = story.questions.map {
|
||||||
|
QuizQuestion(question: $0.question, options: $0.options, correctIndex: $0.correctIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
let annotationsJSON = (try? String(data: JSONEncoder().encode(annotations), encoding: .utf8)) ?? "[]"
|
||||||
|
let questionsJSON = (try? String(data: JSONEncoder().encode(questions), encoding: .utf8)) ?? "[]"
|
||||||
|
|
||||||
|
return Story(
|
||||||
|
title: story.title,
|
||||||
|
bodyES: story.bodyES,
|
||||||
|
bodyEN: story.bodyEN,
|
||||||
|
level: level,
|
||||||
|
wordAnnotations: annotationsJSON,
|
||||||
|
quizQuestions: questionsJSON
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var isAvailable: Bool {
|
||||||
|
SystemLanguageModel.default.availability == .available
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,6 +129,37 @@ struct PracticeView: View {
|
|||||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
.padding(.horizontal)
|
.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
|
// Quick Actions
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("Quick Actions")
|
Text("Quick Actions")
|
||||||
|
|||||||
134
Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift
Normal file
134
Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct StoryLibraryView: View {
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@State private var stories: [Story] = []
|
||||||
|
@State private var isGenerating = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if stories.isEmpty && !isGenerating {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Stories Yet",
|
||||||
|
systemImage: "book.closed",
|
||||||
|
description: Text("Tap + to generate a Spanish story with AI.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
if isGenerating {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Generating story...")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(stories) { story in
|
||||||
|
NavigationLink {
|
||||||
|
StoryReaderView(story: story)
|
||||||
|
} label: {
|
||||||
|
StoryRowView(story: story)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteStories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Stories")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
generateStory()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.disabled(isGenerating || !StoryGenerator.isAvailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: .init(
|
||||||
|
get: { errorMessage != nil },
|
||||||
|
set: { if !$0 { errorMessage = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { errorMessage = nil }
|
||||||
|
} message: {
|
||||||
|
Text(errorMessage ?? "")
|
||||||
|
}
|
||||||
|
.onAppear(perform: loadStories)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadStories() {
|
||||||
|
let descriptor = FetchDescriptor<Story>(
|
||||||
|
sortBy: [SortDescriptor(\Story.createdDate, order: .reverse)]
|
||||||
|
)
|
||||||
|
stories = (try? cloudModelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateStory() {
|
||||||
|
guard !isGenerating else { return }
|
||||||
|
|
||||||
|
guard StoryGenerator.isAvailable else {
|
||||||
|
errorMessage = "Apple Intelligence is not available on this device. Stories require an iPhone 15 Pro or later with Apple Intelligence enabled."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isGenerating = true
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
|
let level = progress.selectedLevel
|
||||||
|
let tenses = progress.enabledTenseIDs
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let story = try await StoryGenerator.generate(level: level, tenses: tenses)
|
||||||
|
cloudModelContext.insert(story)
|
||||||
|
try? cloudModelContext.save()
|
||||||
|
loadStories()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to generate story: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
isGenerating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteStories(at offsets: IndexSet) {
|
||||||
|
for index in offsets {
|
||||||
|
cloudModelContext.delete(stories[index])
|
||||||
|
}
|
||||||
|
try? cloudModelContext.save()
|
||||||
|
loadStories()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Story Row
|
||||||
|
|
||||||
|
private struct StoryRowView: View {
|
||||||
|
let story: Story
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(story.title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(story.level.capitalized)
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundStyle(.teal)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.teal.opacity(0.12), in: Capsule())
|
||||||
|
|
||||||
|
Text(story.createdDate.formatted(date: .abbreviated, time: .omitted))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
152
Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift
Normal file
152
Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
|
struct StoryQuizView: View {
|
||||||
|
let story: Story
|
||||||
|
|
||||||
|
@State private var currentIndex = 0
|
||||||
|
@State private var selectedOption: Int?
|
||||||
|
@State private var correctCount = 0
|
||||||
|
@State private var isFinished = false
|
||||||
|
|
||||||
|
private var questions: [QuizQuestion] { story.decodedQuestions }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
if isFinished {
|
||||||
|
finishedView
|
||||||
|
} else if let question = questions[safe: currentIndex] {
|
||||||
|
questionView(question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 600)
|
||||||
|
.navigationTitle("Comprehension Quiz")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Question View
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func questionView(_ question: QuizQuestion) -> some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Progress
|
||||||
|
Text("Question \(currentIndex + 1) of \(questions.count)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
// Question
|
||||||
|
Text(question.question)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
// Options
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(Array(question.options.enumerated()), id: \.offset) { index, option in
|
||||||
|
Button {
|
||||||
|
guard selectedOption == nil else { return }
|
||||||
|
selectedOption = index
|
||||||
|
if index == question.correctIndex {
|
||||||
|
correctCount += 1
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(option)
|
||||||
|
.font(.body)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
Spacer()
|
||||||
|
if let selected = selectedOption {
|
||||||
|
if index == question.correctIndex {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else if index == selected {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(optionBackground(index: index, correct: question.correctIndex), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
if selectedOption != nil {
|
||||||
|
Button {
|
||||||
|
if currentIndex + 1 < questions.count {
|
||||||
|
currentIndex += 1
|
||||||
|
selectedOption = nil
|
||||||
|
} else {
|
||||||
|
withAnimation { isFinished = true }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(currentIndex + 1 < questions.count ? "Next Question" : "See Results")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.teal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Finished View
|
||||||
|
|
||||||
|
private var finishedView: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: correctCount == questions.count ? "star.fill" : "checkmark.circle")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(correctCount == questions.count ? .yellow : .teal)
|
||||||
|
|
||||||
|
Text("\(correctCount) / \(questions.count)")
|
||||||
|
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||||
|
|
||||||
|
Text(scoreMessage)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scoreMessage: String {
|
||||||
|
switch correctCount {
|
||||||
|
case questions.count: return "Perfect score!"
|
||||||
|
case _ where correctCount > questions.count / 2: return "Good job! Keep reading."
|
||||||
|
default: return "Try re-reading the story and quiz again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func optionBackground(index: Int, correct: Int) -> some ShapeStyle {
|
||||||
|
guard let selected = selectedOption else {
|
||||||
|
return AnyShapeStyle(.fill.quaternary)
|
||||||
|
}
|
||||||
|
if index == correct {
|
||||||
|
return AnyShapeStyle(.green.opacity(0.15))
|
||||||
|
}
|
||||||
|
if index == selected {
|
||||||
|
return AnyShapeStyle(.red.opacity(0.15))
|
||||||
|
}
|
||||||
|
return AnyShapeStyle(.fill.quaternary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Collection {
|
||||||
|
subscript(safe index: Index) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
319
Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift
Normal file
319
Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift
Normal file
@@ -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<Content: View>: View {
|
||||||
|
let words: [String]
|
||||||
|
let content: (String) -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
FlowLayout(spacing: 0) {
|
||||||
|
ForEach(Array(words.enumerated()), id: \.offset) { _, word in
|
||||||
|
content(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 0
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||||
|
var height: CGFloat = 0
|
||||||
|
for row in rows {
|
||||||
|
height += row.map { $0.height }.max() ?? 0
|
||||||
|
}
|
||||||
|
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||||
|
return CGSize(width: proposal.width ?? 0, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||||
|
var y = bounds.minY
|
||||||
|
var subviewIndex = 0
|
||||||
|
for row in rows {
|
||||||
|
var x = bounds.minX
|
||||||
|
let rowHeight = row.map { $0.height }.max() ?? 0
|
||||||
|
for size in row {
|
||||||
|
subviews[subviewIndex].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||||
|
x += size.width
|
||||||
|
subviewIndex += 1
|
||||||
|
}
|
||||||
|
y += rowHeight + spacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||||
|
let maxWidth = proposal.width ?? .infinity
|
||||||
|
var rows: [[CGSize]] = [[]]
|
||||||
|
var currentWidth: CGFloat = 0
|
||||||
|
|
||||||
|
for subview in subviews {
|
||||||
|
let size = subview.sizeThatFits(.unspecified)
|
||||||
|
if currentWidth + size.width > maxWidth && !rows[rows.count - 1].isEmpty {
|
||||||
|
rows.append([])
|
||||||
|
currentWidth = 0
|
||||||
|
}
|
||||||
|
rows[rows.count - 1].append(size)
|
||||||
|
currentWidth += size.width
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Word Detail Sheet
|
||||||
|
|
||||||
|
private struct WordDetailSheet: View {
|
||||||
|
let word: WordAnnotation
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
Text(word.word)
|
||||||
|
.font(.title2.bold())
|
||||||
|
Spacer()
|
||||||
|
if !word.partOfSpeech.isEmpty {
|
||||||
|
Text(word.partOfSpeech)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(.fill.tertiary, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if word.english == "Looking up..." {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Looking up word...")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if !word.baseForm.isEmpty && word.baseForm != word.word {
|
||||||
|
HStack {
|
||||||
|
Text("Base form:")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(word.baseForm)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !word.english.isEmpty {
|
||||||
|
HStack {
|
||||||
|
Text("English:")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(word.english)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - On-Demand Word Lookup
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private enum WordLookup {
|
||||||
|
@Generable
|
||||||
|
struct WordInfo {
|
||||||
|
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
|
||||||
|
var baseForm: String
|
||||||
|
@Guide(description: "English translation")
|
||||||
|
var english: String
|
||||||
|
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, conjunction, article, pronoun, or other")
|
||||||
|
var partOfSpeech: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func lookup(word: String, inContext sentence: String) async throws -> WordAnnotation {
|
||||||
|
let session = LanguageModelSession(instructions: """
|
||||||
|
You are a Spanish dictionary. Given a word and the sentence it appears in, \
|
||||||
|
provide its base form, English translation, and part of speech.
|
||||||
|
""")
|
||||||
|
|
||||||
|
let response = try await session.respond(
|
||||||
|
to: "Word: \"\(word)\" in sentence: \"\(sentence)\"",
|
||||||
|
generating: WordInfo.self
|
||||||
|
)
|
||||||
|
|
||||||
|
let info = response.content
|
||||||
|
return WordAnnotation(
|
||||||
|
word: word,
|
||||||
|
baseForm: info.baseForm,
|
||||||
|
english: info.english,
|
||||||
|
partOfSpeech: info.partOfSpeech
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Conjuga/SharedModels/Sources/SharedModels/Story.swift
Normal file
67
Conjuga/SharedModels/Sources/SharedModels/Story.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Model
|
||||||
|
public final class Story {
|
||||||
|
public var id: String = ""
|
||||||
|
public var title: String = ""
|
||||||
|
public var bodyES: String = ""
|
||||||
|
public var bodyEN: String = ""
|
||||||
|
public var level: String = ""
|
||||||
|
public var wordAnnotations: String = "[]"
|
||||||
|
public var quizQuestions: String = "[]"
|
||||||
|
public var createdDate: Date = Date()
|
||||||
|
|
||||||
|
public init(title: String, bodyES: String, bodyEN: String, level: String, wordAnnotations: String, quizQuestions: String) {
|
||||||
|
self.id = UUID().uuidString
|
||||||
|
self.title = title
|
||||||
|
self.bodyES = bodyES
|
||||||
|
self.bodyEN = bodyEN
|
||||||
|
self.level = level
|
||||||
|
self.wordAnnotations = wordAnnotations
|
||||||
|
self.quizQuestions = quizQuestions
|
||||||
|
self.createdDate = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON Helpers
|
||||||
|
|
||||||
|
public struct WordAnnotation: Codable, Identifiable, Hashable {
|
||||||
|
public var id: String { word }
|
||||||
|
public let word: String
|
||||||
|
public let baseForm: String
|
||||||
|
public let english: String
|
||||||
|
public let partOfSpeech: String
|
||||||
|
|
||||||
|
public init(word: String, baseForm: String, english: String, partOfSpeech: String) {
|
||||||
|
self.word = word
|
||||||
|
self.baseForm = baseForm
|
||||||
|
self.english = english
|
||||||
|
self.partOfSpeech = partOfSpeech
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct QuizQuestion: Codable, Identifiable, Hashable {
|
||||||
|
public var id: String { question }
|
||||||
|
public let question: String
|
||||||
|
public let options: [String]
|
||||||
|
public let correctIndex: Int
|
||||||
|
|
||||||
|
public init(question: String, options: [String], correctIndex: Int) {
|
||||||
|
self.question = question
|
||||||
|
self.options = options
|
||||||
|
self.correctIndex = correctIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Story {
|
||||||
|
public var decodedAnnotations: [WordAnnotation] {
|
||||||
|
guard let data = wordAnnotations.data(using: .utf8) else { return [] }
|
||||||
|
return (try? JSONDecoder().decode([WordAnnotation].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
public var decodedQuestions: [QuizQuestion] {
|
||||||
|
guard let data = quizQuestions.data(using: .utf8) else { return [] }
|
||||||
|
return (try? JSONDecoder().decode([QuizQuestion].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user