Files
Spanish/Conjuga/Conjuga/Views/Practice/NounReviewView.swift
T
Trey T 696eafa64f Noun & adjective practice — Multiple Choice, Review Learned, Review
Mirror the four-entry Vocabulary section for nouns and adjectives, so each
POS gets the same set of practice modes the verb flow already had:

- Noun/Adjective Flashcards (existing) — English → Spanish reveal with
  article for nouns. Now accepts `kind:` to share the view with the
  Review-Learned cram pass.
- Noun/Adjective Multiple Choice — English prompt, 4 Spanish options
  drawn from the current session pool (1 correct + 3 random distractors).
  Same SRS rating writes as Flashcards.
- Review Learned — `NounFlashcardPracticeView(kind: .reviewLearned)` and
  the adjective equivalent. Cycles through already-studied lexemes with
  no schedule changes; mirrors `VocabFlashcardPracticeView`'s
  reviewLearned kind.
- Noun/Adjective Review — fetches due `LexemeReviewCard` rows by POS,
  Spanish-front / English-reveal flashcards rated directly against the
  SRS schedule. Each exposes a static `dueCount(context:)` used by the
  practice-row badge.

Wiring:
- New `LexemeSessionKind` enum (standard / reviewLearned) in
  LexemeSessionQueue.swift, mirroring `VocabSessionKind`.
- Noun + Adjective Flashcard views branch load/persist/answer on `kind`
  so Review Learned doesn't touch the persisted study group or reschedule
  cross-session SRS.
- Practice screen gets dedicated "Nouns" and "Adjectives" sections
  (between Vocabulary and Reading), each with 4 NavigationLinks shaped
  exactly like the Vocabulary section. The previous single-link Noun and
  Adjective entries in the Reading section are removed.
- PracticeView caches `nounDueCount` / `adjectiveDueCount` in @State and
  refreshes on appear + after sessions end, so the badge doesn't trigger
  LexemeReviewCard fetchCount on every body re-evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:59:42 -05:00

198 lines
6.8 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
/// Due-card review for the noun flashcard SRS the non-verb analog of
/// `VocabReviewView`. Pulls every `LexemeReviewCard` with
/// `partOfSpeech == "noun"` whose `dueDate` is in the past, shows the
/// Spanish word with its article on the front, reveals the English, then
/// rates via the SRS so the schedule moves forward.
struct NounReviewView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.modelContext) private var localContext
@Environment(\.dismiss) private var dismiss
@State private var dueCards: [LexemeReviewCard] = []
@State private var lexemesByID: [String: Lexeme] = [:]
@State private var currentIndex = 0
@State private var isRevealed = false
@State private var sessionCorrect = 0
@State private var sessionTotal = 0
@State private var isFinished = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
VStack(spacing: 20) {
if isFinished || dueCards.isEmpty {
finishedView
} else if let card = dueCards[safe: currentIndex] {
cardView(card)
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Noun Review")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadDueCards)
}
@ViewBuilder
private func cardView(_ card: LexemeReviewCard) -> some View {
let lexeme = lexemesByID[card.lexemeId]
VStack(spacing: 24) {
Text("\(currentIndex + 1) of \(dueCards.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
.tint(.teal)
Spacer()
Text(spanishFront(lexeme))
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
if isRevealed {
Text(lexeme?.english ?? "")
.font(.title2)
.foregroundStyle(.secondary)
.transition(.opacity.combined(with: .move(edge: .bottom)))
Spacer()
HStack(spacing: 12) {
ratingButton("Again", color: .red, quality: .again)
ratingButton("Hard", color: .orange, quality: .hard)
ratingButton("Good", color: .green, quality: .good)
ratingButton("Easy", color: .blue, quality: .easy)
}
} else {
Spacer()
Button {
withAnimation { isRevealed = true }
} label: {
Text("Show Answer")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.teal)
}
}
}
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
.font(.system(size: 60))
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
if dueCards.isEmpty {
Text("All caught up!").font(.title2.bold())
Text("No noun cards are due for review.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("\(sessionCorrect) / \(sessionTotal)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text("Review complete!").font(.title3).foregroundStyle(.secondary)
}
Spacer()
}
}
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
Button {
rate(quality: quality)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.bordered)
.tint(color)
}
private func rate(quality: ReviewQuality) {
guard let card = dueCards[safe: currentIndex] else { return }
ReviewStore.recordActivity(context: cloudContext)
let result = SRSEngine.review(
quality: quality,
currentEase: card.easeFactor,
currentInterval: card.interval,
currentReps: card.repetitions
)
card.easeFactor = result.easeFactor
card.interval = result.interval
card.repetitions = result.repetitions
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
card.lastReviewDate = Date()
try? cloudContext.save()
sessionTotal += 1
if quality != .again { sessionCorrect += 1 }
isRevealed = false
if currentIndex + 1 < dueCards.count {
currentIndex += 1
} else {
withAnimation { isFinished = true }
}
}
private func loadDueCards() {
let now = Date()
let pos = "noun"
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == pos && $0.dueDate <= now
},
sortBy: [SortDescriptor(\LexemeReviewCard.dueDate)]
)
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
let ids = Set(dueCards.map(\.lexemeId))
let lexDesc = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
)
let all = (try? localContext.fetch(lexDesc)) ?? []
lexemesByID = Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
}
static func dueCount(context: ModelContext) -> Int {
let now = Date()
let pos = "noun"
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == pos && $0.dueDate <= now
}
)
return (try? context.fetchCount(descriptor)) ?? 0
}
private func spanishFront(_ lexeme: Lexeme?) -> String {
guard let lexeme else { return "" }
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
let article: String
switch g {
case "f": article = "la"
case "m/f": article = "el/la"
default: article = "el"
}
return "\(article) \(lexeme.baseForm)"
}
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}