Files
Spanish/Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift
Trey t 451866e988 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>
2026-04-13 11:31:58 -05:00

135 lines
4.3 KiB
Swift

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