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:
150
Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift
Normal file
150
Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user