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>
This commit is contained in:
Trey t
2026-04-13 16:12:36 -05:00
parent b13f58ec81
commit a663bc03cd
20 changed files with 2253 additions and 19 deletions

View File

@@ -0,0 +1,150 @@
import SwiftUI
import SharedModels
import SwiftData
struct ChatView: View {
let conversation: Conversation
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var service = ConversationService()
@State private var messages: [ChatMessage] = []
@State private var inputText = ""
@State private var errorMessage: String?
@State private var hasStarted = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
VStack(spacing: 0) {
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(messages) { message in
ChatBubble(message: message)
.id(message.id)
}
if service.isResponding {
HStack {
ProgressView()
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 16))
Spacer()
}
.padding(.horizontal)
}
}
.padding(.vertical)
}
.onChange(of: messages.count) {
if let last = messages.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
Divider()
// Input
HStack(spacing: 8) {
TextField("Type in Spanish...", text: $inputText)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.sentences)
.onSubmit { sendMessage() }
Button {
sendMessage()
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.disabled(inputText.trimmingCharacters(in: .whitespaces).isEmpty || service.isResponding)
.tint(.green)
}
.padding()
}
.navigationTitle(conversation.scenario)
.navigationBarTitleDisplayMode(.inline)
.alert("Error", isPresented: .init(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
Button("OK") { errorMessage = nil }
} message: {
Text(errorMessage ?? "")
}
.onAppear {
messages = conversation.decodedMessages
if !hasStarted && messages.isEmpty {
startConversation()
}
hasStarted = true
}
}
private func startConversation() {
let opening = service.startConversation(scenario: conversation.scenario, level: conversation.level)
let msg = ChatMessage(role: "assistant", content: opening)
conversation.appendMessage(msg)
messages = conversation.decodedMessages
try? cloudContext.save()
}
private func sendMessage() {
let text = inputText.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { return }
let userMsg = ChatMessage(role: "user", content: text)
conversation.appendMessage(userMsg)
messages = conversation.decodedMessages
inputText = ""
try? cloudContext.save()
Task {
do {
let response = try await service.respond(to: text)
let assistantMsg = ChatMessage(role: "assistant", content: response)
conversation.appendMessage(assistantMsg)
messages = conversation.decodedMessages
try? cloudContext.save()
} catch {
errorMessage = "Failed to get response: \(error.localizedDescription)"
}
}
}
}
// MARK: - Chat Bubble
private struct ChatBubble: View {
let message: ChatMessage
private var isUser: Bool { message.role == "user" }
var body: some View {
HStack {
if isUser { Spacer(minLength: 60) }
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
Text(message.content)
.font(.body)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(isUser ? AnyShapeStyle(.green.opacity(0.2)) : AnyShapeStyle(.fill.quaternary), in: RoundedRectangle(cornerRadius: 16))
if let correction = message.correction, !correction.isEmpty {
Text(correction)
.font(.caption)
.foregroundStyle(.orange)
.padding(.horizontal, 4)
}
}
if !isUser { Spacer(minLength: 60) }
}
.padding(.horizontal)
}
}