Files
Spanish/Conjuga/Conjuga/Views/Practice/PracticeView.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

574 lines
22 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
struct PracticeView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var viewModel = PracticeViewModel()
@State private var speechService = SpeechService()
@State private var isPracticing = false
@State private var userProgress: UserProgress?
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View {
NavigationStack {
Group {
if isPracticing {
practiceSessionView
} else {
practiceHomeView
}
}
.navigationTitle("Practice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadProgress)
.onChange(of: isPracticing) { _, practicing in
if !practicing {
loadProgress()
}
}
.toolbar {
if isPracticing {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
withAnimation {
isPracticing = false
}
}
}
ToolbarItem(placement: .primaryAction) {
sessionStatsLabel
}
}
}
}
}
// MARK: - Home View
private var practiceHomeView: some View {
ScrollView {
VStack(spacing: 28) {
// Daily progress
if let progress = userProgress {
VStack(spacing: 8) {
DailyProgressRing(
current: progress.todayCount,
goal: progress.dailyGoal
)
.frame(width: 160, height: 160)
Text("\(progress.todayCount) / \(progress.dailyGoal)")
.font(.title3.weight(.semibold))
.foregroundStyle(.secondary)
if progress.currentStreak > 0 {
Label("\(progress.currentStreak) day streak", systemImage: "flame.fill")
.font(.subheadline.weight(.medium))
.foregroundStyle(.orange)
}
}
.padding(.top, 8)
}
// Mode selection
VStack(spacing: 12) {
Text("Choose a Mode")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(PracticeMode.allCases) { mode in
ModeButton(mode: mode) {
viewModel.practiceMode = mode
viewModel.focusMode = .none
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation {
isPracticing = true
}
}
}
}
.padding(.horizontal)
// Lyrics
NavigationLink {
LyricsLibraryView()
} label: {
HStack(spacing: 14) {
Image(systemName: "music.note.list")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.pink)
VStack(alignment: .leading, spacing: 2) {
Text("Lyrics")
.font(.subheadline.weight(.semibold))
Text("Read Spanish song lyrics with translations")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Conversation Practice
NavigationLink {
ChatLibraryView()
} label: {
HStack(spacing: 14) {
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Conversation")
.font(.subheadline.weight(.semibold))
Text("Chat with AI in Spanish scenarios")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Listening Practice
NavigationLink {
ListeningView()
} label: {
HStack(spacing: 14) {
Image(systemName: "ear.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("Listening")
.font(.subheadline.weight(.semibold))
Text("Listen and type, or practice pronunciation")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Cloze Practice
NavigationLink {
ClozeView()
} label: {
HStack(spacing: 14) {
Image(systemName: "text.badge.minus")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.indigo)
VStack(alignment: .leading, spacing: 2) {
Text("Cloze Practice")
.font(.subheadline.weight(.semibold))
Text("Fill in the missing word in context")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Stories
NavigationLink {
StoryLibraryView()
} label: {
HStack(spacing: 14) {
Image(systemName: "book.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.teal)
VStack(alignment: .leading, spacing: 2) {
Text("Stories")
.font(.subheadline.weight(.semibold))
Text("Read AI-generated Spanish stories")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Quick Actions
VStack(spacing: 12) {
Text("Quick Actions")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
// Vocab review
NavigationLink {
VocabReviewView()
} label: {
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.teal)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Vocab Review")
.font(.subheadline.weight(.semibold))
Text("Review due vocabulary cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
if dueCount > 0 {
Text("\(dueCount)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.teal, in: Capsule())
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Common tenses focus
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .commonTenses
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { isPracticing = true }
} label: {
HStack(spacing: 14) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.orange)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Common Tenses")
.font(.subheadline.weight(.semibold))
Text("Practice the 6 most essential tenses")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Weak verbs focus
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .weakVerbs
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { isPracticing = true }
} label: {
HStack(spacing: 14) {
Image(systemName: "exclamationmark.triangle")
.font(.title3)
.foregroundStyle(.red)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Weak Verbs")
.font(.subheadline.weight(.semibold))
Text("Focus on verbs you struggle with")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Irregularity drills
Menu {
Button("Spelling Changes (c→qu, z→c, ...)") {
startIrregularityDrill(.spelling)
}
Button("Stem Changes (o→ue, e→ie, ...)") {
startIrregularityDrill(.stemChange)
}
Button("Unique Irregulars (ser, ir, ...)") {
startIrregularityDrill(.uniqueIrregular)
}
} label: {
HStack(spacing: 14) {
Image(systemName: "wand.and.stars")
.font(.title3)
.foregroundStyle(.purple)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Irregularity Drills")
.font(.subheadline.weight(.semibold))
Text("Practice by irregularity type")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
// Session stats summary
if viewModel.sessionTotal > 0 && !isPracticing {
VStack(spacing: 8) {
Text("Last Session")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 20) {
StatItem(label: "Reviewed", value: "\(viewModel.sessionTotal)")
StatItem(label: "Correct", value: "\(viewModel.sessionCorrect)")
StatItem(
label: "Accuracy",
value: "\(Int(viewModel.sessionAccuracy * 100))%"
)
}
.padding()
.frame(maxWidth: .infinity)
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
}
.padding(.horizontal)
}
}
.padding(.vertical)
.adaptiveContainer()
}
}
// MARK: - Practice Session View
@ViewBuilder
private var practiceSessionView: some View {
if !viewModel.hasCards {
ContentUnavailableView(
"No Cards Available",
systemImage: "rectangle.on.rectangle.slash",
description: Text("Add some verbs to your practice deck to get started.")
)
} else {
switch viewModel.practiceMode {
case .flashcard:
FlashcardView(viewModel: viewModel, speechService: speechService)
case .typing:
TypingView(viewModel: viewModel, speechService: speechService)
case .multipleChoice:
MultipleChoiceView(viewModel: viewModel, speechService: speechService)
case .fullTable:
FullTableView(speechService: speechService)
case .handwriting:
HandwritingView(viewModel: viewModel, speechService: speechService)
case .sentenceBuilder:
SentenceBuilderView()
}
}
}
// MARK: - Session Stats Label
private var sessionStatsLabel: some View {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(viewModel.sessionCorrect)/\(viewModel.sessionTotal)")
.font(.subheadline.weight(.medium))
.contentTransition(.numericText())
}
}
}
// MARK: - Mode Button
private struct ModeButton: View {
let mode: PracticeMode
let action: () -> Void
private var description: String {
switch mode {
case .flashcard: "Reveal answers at your own pace"
case .typing: "Type the conjugation from memory"
case .multipleChoice: "Pick the correct form from options"
case .fullTable: "Conjugate all persons for a verb + tense"
case .handwriting: "Write the answer with Apple Pencil"
case .sentenceBuilder: "Arrange Spanish words in correct order"
}
}
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
Image(systemName: mode.icon)
.font(.title2)
.frame(width: 40)
VStack(alignment: .leading, spacing: 2) {
Text(mode.label)
.font(.headline)
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.subheadline)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
}
}
// MARK: - Stat Item
private struct StatItem: View {
let label: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.title2.bold().monospacedDigit())
.contentTransition(.numericText())
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
extension PracticeView {
fileprivate func startIrregularityDrill(_ filter: IrregularityFilter) {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .irregularity(filter)
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
withAnimation { isPracticing = true }
}
private func loadProgress() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
userProgress = progress
try? cloudModelContext.save()
}
}
#Preview {
PracticeView()
.modelContainer(for: [UserProgress.self, ReviewCard.self, Verb.self], inMemory: true)
}