Files
Spanish/Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift
Trey t a663bc03cd Add 6 new practice features, offline dictionary, and feature reference
New features:
- Offline Dictionary: reverse index of 175K verb forms + 200 common
  words, cached to disk, powers instant word lookups in Stories
- Vocab SRS Review: spaced repetition for course vocabulary cards
  with due count badge and Again/Hard/Good/Easy rating
- Cloze Practice: fill-in-the-blank using SentenceQuizEngine with
  distractor generation from vocabulary pool
- Grammar Exercises: interactive quizzes for 5 grammar topics
  (ser/estar, por/para, preterite/imperfect, subjunctive, personal a)
  with "Practice This" button on grammar note detail
- Listening Practice: listen-and-type + pronunciation check modes
  using Speech framework with word-by-word match scoring
- Conversational Practice: AI chat partner via Foundation Models
  with 10 scenario types, saved to cloud container

Other changes:
- Add Conversation model to SharedModels and cloud container
- Add Info.plist keys for speech recognition and microphone
- Skip speech auth on simulator to prevent crash
- Fix preparing data screen to only show during seed/migration
- Extract courseDataVersion to static property on DataLoader
- Add "How Features Work" reference page in Settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:12:36 -05:00

127 lines
4.6 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
struct ChatLibraryView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var conversations: [Conversation] = []
@State private var showingScenarioPicker = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
Group {
if conversations.isEmpty {
ContentUnavailableView(
"No Conversations Yet",
systemImage: "bubble.left.and.bubble.right",
description: Text("Tap + to start a Spanish conversation.")
)
} else {
List {
ForEach(conversations) { conv in
NavigationLink {
ChatView(conversation: conv)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(conv.scenario)
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Text(conv.level.capitalized)
.font(.caption2.weight(.medium))
.foregroundStyle(.green)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.green.opacity(0.12), in: Capsule())
let msgCount = conv.decodedMessages.count
Text("\(msgCount) message\(msgCount == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
.onDelete(perform: deleteConversations)
}
}
}
.navigationTitle("Conversations")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingScenarioPicker = true
} label: {
Image(systemName: "plus")
}
.disabled(!ConversationService.isAvailable)
}
}
.sheet(isPresented: $showingScenarioPicker) {
ScenarioPickerView { scenario in
showingScenarioPicker = false
createConversation(scenario: scenario)
}
}
.onAppear(perform: loadConversations)
}
private func loadConversations() {
let descriptor = FetchDescriptor<Conversation>(
sortBy: [SortDescriptor(\Conversation.createdDate, order: .reverse)]
)
conversations = (try? cloudContext.fetch(descriptor)) ?? []
}
private func createConversation(scenario: String) {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let conv = Conversation(scenario: scenario, level: progress.selectedLevel)
cloudContext.insert(conv)
try? cloudContext.save()
loadConversations()
}
private func deleteConversations(at offsets: IndexSet) {
for index in offsets { cloudContext.delete(conversations[index]) }
try? cloudContext.save()
loadConversations()
}
}
// MARK: - Scenario Picker
struct ScenarioPickerView: View {
let onPick: (String) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(ConversationService.scenarios, id: \.self) { scenario in
Button {
onPick(scenario)
} label: {
HStack {
Text(scenario)
.font(.body)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.tint(.primary)
}
}
.navigationTitle("Choose a Scenario")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}