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>
135 lines
4.3 KiB
Swift
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)
|
|
}
|
|
}
|