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>
127 lines
4.6 KiB
Swift
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() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|