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>
574 lines
22 KiB
Swift
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)
|
|
}
|