Add AI-generated short stories with tappable words and comprehension quiz
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) <noreply@anthropic.com>
This commit is contained in:
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user