aab64116b3
- 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>
830 lines
31 KiB
Swift
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)
|
|
}
|