Files
Spanish/Conjuga/Conjuga/Views/Practice/PracticeView.swift
T
Trey T aab64116b3 Vocab study — per-type session sizes + Review Learned multiple choice
- Settings: split the single session-size picker into separate Verbs /
  Nouns / Adjectives pickers. Nouns and adjectives previously shared one
  hidden limit; they now use nounSessionCardLimit / adjectiveSessionCardLimit.
- LexemePool.sessionCardLimit is now per part-of-speech.
- Multiple-choice views (verb/noun/adjective) gained a kind param so
  Review Learned can run as multiple choice, not just flashcards. The
  cram pass drives the in-session queue only and leaves the long-term
  SRS schedule untouched.
- PracticeView: each section now offers Review Learned — Flashcards and
  Review Learned — Multiple Choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:54:10 -05:00

830 lines
31 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?
/// Cached due counts for the noun + adjective Review rows. Refreshed on
/// appear, on session end (`isPracticing` change), and after the user
/// returns from a Review screen. Avoids running `fetchCount` against the
/// cloud context on every `body` re-evaluation.
@State private var nounDueCount: Int = 0
@State private var adjectiveDueCount: Int = 0
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View {
NavigationStack {
Group {
if isPracticing {
practiceSessionView
} else {
practiceHomeView
}
}
// Book navigation is value-based and declared once here, at the
// stack root. Eager `NavigationLink { destination }` forms inside
// the List/LazyVStack of the book screens caused an infinite
// render loop; value-based links build destinations lazily.
.navigationDestination(for: BooksRoute.self) { _ in
BookLibraryView()
}
.navigationDestination(for: Book.self) { book in
BookChapterListView(book: book)
}
.navigationDestination(for: BookChapter.self) { chapter in
BookReaderView(chapter: chapter)
}
.navigationTitle("Practice")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
loadProgress()
refreshLexemeDueCounts()
}
.onChange(of: isPracticing) { _, practicing in
if !practicing {
loadProgress()
refreshLexemeDueCounts()
}
}
.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)
}
// === Section: Conjugation ===
sectionHeader("Conjugation")
VStack(spacing: 12) {
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)
conjugationFocusButtons
// === Section: Vocabulary ===
sectionHeader("Vocabulary")
vocabSection
// === Section: Nouns ===
sectionHeader("Nouns")
nounsSection
// === Section: Adjectives ===
sectionHeader("Adjectives")
adjectivesSection
// === Section: Reading ===
sectionHeader("Reading")
// 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)
// Books
NavigationLink(value: BooksRoute.library) {
HStack(spacing: 14) {
Image(systemName: "books.vertical.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.indigo)
VStack(alignment: .leading, spacing: 2) {
Text("Books")
.font(.subheadline.weight(.semibold))
Text("Read full-length books with tap-to-define")
.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)
// 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: - Section header
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
// MARK: - Conjugation focus buttons (Common Tenses / Weak Verbs / Irregularity)
private var conjugationFocusButtons: some View {
VStack(spacing: 12) {
// Common Tenses
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .commonTenses
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
withAnimation { isPracticing = true }
} label: {
practiceRowLabel(icon: "star.fill", color: .orange,
title: "Common Tenses",
subtitle: "Practice the 6 most essential tenses")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Weak Verbs
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .weakVerbs
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
withAnimation { isPracticing = true }
} label: {
practiceRowLabel(icon: "exclamationmark.triangle", color: .red,
title: "Weak Verbs",
subtitle: "Focus on verbs you struggle with")
}
.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: {
practiceRowLabel(icon: "wand.and.stars", color: .purple,
title: "Irregularity Drills",
subtitle: "Practice by irregularity type")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
}
// MARK: - Vocabulary section
private var vocabSection: some View {
VStack(spacing: 12) {
// Vocab Flashcards (verb pool, filtered by Settings levels)
NavigationLink {
VocabFlashcardPracticeView()
} label: {
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple,
title: "Vocab Flashcards",
subtitle: "Verb meaning → infinitive recall")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Vocab Multiple Choice (same verb pool)
NavigationLink {
VocabMultipleChoicePracticeView()
} label: {
practiceRowLabel(icon: "checklist", color: .purple,
title: "Vocab Multiple Choice",
subtitle: "Pick the Spanish infinitive from 4 options")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Review Learned consolidation cram over already-studied verbs
NavigationLink {
VocabFlashcardPracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
title: "Review Learned — Flashcards",
subtitle: "Re-review verbs you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
VocabMultipleChoicePracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
title: "Review Learned — Multiple Choice",
subtitle: "Multiple choice over verbs you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Existing: Vocab Review (due cards)
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))
}
.padding(.horizontal)
}
// MARK: - Nouns section
private var nounsSection: some View {
VStack(spacing: 12) {
NavigationLink {
NounFlashcardPracticeView()
} label: {
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .teal,
title: "Noun Flashcards",
subtitle: "English → Spanish noun (with article)")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
NounMultipleChoicePracticeView()
} label: {
practiceRowLabel(icon: "checklist", color: .teal,
title: "Noun Multiple Choice",
subtitle: "Pick the Spanish noun from 4 options")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
NounFlashcardPracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal,
title: "Review Learned — Flashcards",
subtitle: "Re-review nouns you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
NounMultipleChoicePracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal,
title: "Review Learned — Multiple Choice",
subtitle: "Multiple choice over nouns you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
NounReviewView()
} label: {
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.teal)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Noun Review")
.font(.subheadline.weight(.semibold))
Text("Review due noun cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if nounDueCount > 0 {
Text("\(nounDueCount)")
.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))
}
.padding(.horizontal)
}
// MARK: - Adjectives section
private var adjectivesSection: some View {
VStack(spacing: 12) {
NavigationLink {
AdjectiveFlashcardPracticeView()
} label: {
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .pink,
title: "Adjective Flashcards",
subtitle: "English → Spanish adjective base form")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
AdjectiveMultipleChoicePracticeView()
} label: {
practiceRowLabel(icon: "checklist", color: .pink,
title: "Adjective Multiple Choice",
subtitle: "Pick the Spanish adjective from 4 options")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
AdjectiveFlashcardPracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink,
title: "Review Learned — Flashcards",
subtitle: "Re-review adjectives you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
AdjectiveMultipleChoicePracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink,
title: "Review Learned — Multiple Choice",
subtitle: "Multiple choice over adjectives you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
AdjectiveReviewView()
} label: {
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.pink)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Adjective Review")
.font(.subheadline.weight(.semibold))
Text("Review due adjective cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if adjectiveDueCount > 0 {
Text("\(adjectiveDueCount)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.pink, in: Capsule())
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
}
private func practiceRowLabel(icon: String, color: Color, title: String, subtitle: String) -> some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
// 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 refreshLexemeDueCounts() {
nounDueCount = NounReviewView.dueCount(context: cloudModelContext)
adjectiveDueCount = AdjectiveReviewView.dueCount(context: cloudModelContext)
}
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)
}