696eafa64f
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>
198 lines
6.8 KiB
Swift
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
|
|
}
|
|
}
|