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>
This commit is contained in:
Trey T
2026-05-19 20:59:42 -05:00
parent 7da98d786c
commit 696eafa64f
9 changed files with 1215 additions and 159 deletions
+16
View File
@@ -20,6 +20,7 @@
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
2088B73D83D4D6A6CABEFBCE /* AdjectiveReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */; };
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; };
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
25A02F8B96285407EFD30817 /* LexemeStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */; };
@@ -27,6 +28,7 @@
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; };
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */; };
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
2B76DE203AEEBAE4536BD4F0 /* NounMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */; };
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
@@ -39,6 +41,7 @@
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; };
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
44C6084491E3056A4C3A890B /* AdjectiveMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */; };
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; };
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
@@ -62,6 +65,7 @@
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
7321D046BB60D40B1D27121E /* NounReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */; };
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.swift */; };
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
@@ -164,7 +168,9 @@
0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; };
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "book_olly-vol2.json"; sourceTree = "<group>"; };
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveReviewView.swift; sourceTree = "<group>"; };
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; };
0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
@@ -182,6 +188,7 @@
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
20D1904DF07E0A6816134CF3 /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
@@ -276,6 +283,7 @@
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounReviewView.swift; sourceTree = "<group>"; };
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
@@ -480,6 +488,7 @@
5A23E5D4EFE8E46030CA9D77 /* Practice */ = {
isa = PBXGroup;
children = (
09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */,
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
@@ -487,6 +496,7 @@
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */,
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
@@ -514,7 +524,9 @@
isa = PBXGroup;
children = (
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */,
256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */,
A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */,
0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */,
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */,
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */,
);
@@ -763,6 +775,8 @@
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
067996B3D0D768ADBED1E52F /* AdjectiveFlashcardPracticeView.swift in Sources */,
44C6084491E3056A4C3A890B /* AdjectiveMultipleChoicePracticeView.swift in Sources */,
2088B73D83D4D6A6CABEFBCE /* AdjectiveReviewView.swift in Sources */,
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */,
@@ -814,6 +828,8 @@
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */,
2B76DE203AEEBAE4536BD4F0 /* NounMultipleChoicePracticeView.swift in Sources */,
7321D046BB60D40B1D27121E /* NounReviewView.swift in Sources */,
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
@@ -2,6 +2,18 @@ import Foundation
import SharedModels
import SwiftData
/// Which pool a `LexemeSessionQueue` draws from. Mirrors `VocabSessionKind`.
enum LexemeSessionKind {
/// Due-first + new lexemes from enabled CEFR levels, capped the
/// standard SRS session. Ratings update the long-term schedule.
case standard
/// Lexemes already studied at least once, most-recent first, uncapped
/// and unfiltered a consolidation cram. Ratings drive the in-session
/// queue only and do NOT reschedule (long-term SM-2 due dates left
/// untouched, parallel to `VocabSessionKind.reviewLearned`).
case reviewLearned
}
/// In-session learning-step queue for `Lexeme`-based vocab practice the
/// non-verb analog of `VocabSessionQueue`. Same Anki-style position-based
/// requeue: Again/Hard requeue close, Good advances state then graduates on
@@ -0,0 +1,185 @@
import SwiftUI
import SharedModels
import SwiftData
/// Due-card review for the adjective flashcard SRS non-verb analog of
/// `VocabReviewView`. Pulls every `LexemeReviewCard` with
/// `partOfSpeech == "adjective"` whose `dueDate` is in the past, shows the
/// Spanish base form on the front, reveals the English, then rates via the
/// SRS so the schedule moves forward.
struct AdjectiveReviewView: 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("Adjective 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(.pink)
Spacer()
Text(lexeme?.baseForm ?? "")
.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(.pink)
}
}
}
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 adjective 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 = "adjective"
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 = "adjective"
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == pos && $0.dueDate <= now
}
)
return (try? context.fetchCount(descriptor)) ?? 0
}
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -0,0 +1,197 @@
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
}
}
+174 -63
View File
@@ -9,6 +9,12 @@ struct PracticeView: View {
@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() }
@@ -36,10 +42,14 @@ struct PracticeView: View {
}
.navigationTitle("Practice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadProgress)
.onAppear {
loadProgress()
refreshLexemeDueCounts()
}
.onChange(of: isPracticing) { _, practicing in
if !practicing {
loadProgress()
refreshLexemeDueCounts()
}
}
.toolbar {
@@ -115,6 +125,14 @@ struct PracticeView: View {
sectionHeader("Vocabulary")
vocabSection
// === Section: Nouns ===
sectionHeader("Nouns")
nounsSection
// === Section: Adjectives ===
sectionHeader("Adjectives")
adjectivesSection
// === Section: Reading ===
sectionHeader("Reading")
@@ -302,68 +320,6 @@ struct PracticeView: View {
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Nouns
NavigationLink {
NounFlashcardPracticeView()
} label: {
HStack(spacing: 14) {
Image(systemName: "n.circle.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.teal)
VStack(alignment: .leading, spacing: 2) {
Text("Nouns")
.font(.subheadline.weight(.semibold))
Text("Flashcards — English ↔ Spanish")
.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)
// Adjectives
NavigationLink {
AdjectiveFlashcardPracticeView()
} label: {
HStack(spacing: 14) {
Image(systemName: "a.circle.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.pink)
VStack(alignment: .leading, spacing: 2) {
Text("Adjectives")
.font(.subheadline.weight(.semibold))
Text("Flashcards — English ↔ Spanish")
.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) {
@@ -532,6 +488,156 @@ struct PracticeView: View {
.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",
subtitle: "Re-review 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",
subtitle: "Re-review 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)
@@ -675,6 +781,11 @@ extension PracticeView {
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
@@ -11,6 +11,8 @@ import SwiftData
/// Plain `ScrollView { VStack }` no `LazyVStack`/`ScrollViewReader`.
struct AdjectiveFlashcardPracticeView: View {
var kind: LexemeSessionKind = .standard
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@@ -36,7 +38,7 @@ struct AdjectiveFlashcardPracticeView: View {
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle("Adjectives")
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Adjectives")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: revealed)
@@ -142,7 +144,7 @@ struct AdjectiveFlashcardPracticeView: View {
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
let graduation = session?.answer(rating)
if let graduation {
if let graduation, kind == .standard {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "adjective",
@@ -209,39 +211,52 @@ struct AdjectiveFlashcardPracticeView: View {
private func loadIfNeeded() {
guard session == nil else { return }
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
drillMode: Self.drillMode
)
if let group = store.activeGroup() {
let stored = group.entries
if !stored.isEmpty {
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
guard let lex = byId[e.lexemeId] else { return nil }
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
}
if entries.count == stored.count {
session = LexemeSessionQueue(
entries: entries,
drillMode: Self.drillMode,
learnedCount: group.learnedCount
)
return
switch kind {
case .reviewLearned:
let lexemes = LexemePool.reviewLearnedLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
return
case .standard:
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
drillMode: Self.drillMode
)
if let group = store.activeGroup() {
let stored = group.entries
if !stored.isEmpty {
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
guard let lex = byId[e.lexemeId] else { return nil }
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
}
if entries.count == stored.count {
session = LexemeSessionQueue(
entries: entries,
drillMode: Self.drillMode,
learnedCount: group.learnedCount
)
return
}
}
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
@@ -253,7 +268,7 @@ struct AdjectiveFlashcardPracticeView: View {
}
private func persistGroup() {
guard let session else { return }
guard kind == .standard, let session else { return }
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
@@ -270,21 +285,26 @@ struct AdjectiveFlashcardPracticeView: View {
}
private func studyAgain() {
LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
drillMode: Self.drillMode
).clear()
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
switch kind {
case .reviewLearned:
session?.restart()
case .standard:
LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
drillMode: Self.drillMode
).clear()
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
revealed = false
persistGroup()
}
}
@@ -0,0 +1,234 @@
import SwiftUI
import SharedModels
import SwiftData
/// English-first adjective multiple choice non-verb analog of
/// `VocabMultipleChoicePracticeView`. Driven by `LexemeSessionQueue` over the
/// adjective pool; 4 options (1 correct + 3 random distractors from the
/// session). Options are bare base forms agreement isn't drilled here.
struct AdjectiveMultipleChoicePracticeView: View {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var session: LexemeSessionQueue?
@State private var distractorPool: [Lexeme] = []
@State private var options: [Lexeme] = []
@State private var selectedOption: Lexeme? = nil
private static let drillMode = "recall"
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentLexeme: Lexeme? { session?.current?.lexeme }
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let lexeme = currentLexeme {
questionBody(lexeme)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle("Adjective Multiple Choice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id)
.animation(.smooth, value: currentLexeme?.id)
}
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0).tint(.pink)
Text(progressLabel).font(.caption).foregroundStyle(.secondary)
}
}
private var progressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
@ViewBuilder
private func questionBody(_ lexeme: Lexeme) -> some View {
Text(lexeme.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if selectedOption == nil {
optionGrid
} else {
revealedContent(lexeme)
}
}
private var optionGrid: some View {
VStack(spacing: 10) {
ForEach(options, id: \.id) { option in
Button {
selectedOption = option
} label: {
Text(option.baseForm)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.tint(.primary)
}
}
}
private func revealedContent(_ lexeme: Lexeme) -> some View {
VStack(spacing: 16) {
answerFeedback(lexeme)
exampleBlock(for: lexeme)
ratingButtons
}
}
private func answerFeedback(_ lexeme: Lexeme) -> some View {
let correct = (selectedOption?.id == lexeme.id)
return VStack(spacing: 6) {
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 36))
.foregroundStyle(correct ? .green : .red)
Text(correct ? "Correct!" : "Not quite")
.font(.headline)
.foregroundStyle(correct ? .green : .red)
Text(lexeme.baseForm)
.font(.title2.weight(.semibold))
.padding(.top, 4)
}
}
@ViewBuilder
private func exampleBlock(for lexeme: Lexeme) -> some View {
if let es = lexeme.exampleES, !es.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(es).font(.subheadline).italic()
if let en = lexeme.exampleEN, !en.isEmpty {
Text(en).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
private var ratingButtons: some View {
VStack(spacing: 10) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, rating: .again)
ratingButton("Hard", color: .orange, rating: .hard)
ratingButton("Good", color: .green, rating: .good)
ratingButton("Easy", color: .blue, rating: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating) -> some View {
Button {
answer(rating)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
.font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button { studyAgain() } label: {
Label("Study Again", systemImage: "arrow.clockwise")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.pink)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 { return "\(learned) adjective\(learned == 1 ? "" : "s") learned" }
return "No adjectives are due right now. Study Again to review anyway."
}
private func loadIfNeeded() {
guard session == nil else { return }
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
distractorPool = lexemes
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
prepareOptions()
}
private func studyAgain() {
session?.restart()
selectedOption = nil
prepareOptions()
}
private func prepareOptions() {
guard let lexeme = currentLexeme else { options = []; return }
let candidates = distractorPool.filter { $0.id != lexeme.id }
let distractors = Array(candidates.shuffled().prefix(3))
options = ([lexeme] + distractors).shuffled()
}
private func answer(_ rating: LexemeSessionQueue.Rating) {
guard let lexeme = currentLexeme else { return }
let graduation = session?.answer(rating)
if let graduation {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "adjective",
drillMode: Self.drillMode,
quality: graduation
)
}
selectedOption = nil
prepareOptions()
}
}
@@ -12,6 +12,8 @@ import SwiftData
/// it out of the books-reader layout-loop class of bug).
struct NounFlashcardPracticeView: View {
var kind: LexemeSessionKind = .standard
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@@ -40,7 +42,7 @@ struct NounFlashcardPracticeView: View {
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle("Nouns")
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Nouns")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: revealed)
@@ -158,7 +160,10 @@ struct NounFlashcardPracticeView: View {
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
let graduation = session?.answer(rating)
if let graduation {
// Review Learned is a cram graduation drives the in-session queue
// only; the cross-session SM-2 schedule is left alone (mirrors the
// verb VocabFlashcardPracticeView reviewLearned behavior).
if let graduation, kind == .standard {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "noun",
@@ -225,39 +230,54 @@ struct NounFlashcardPracticeView: View {
private func loadIfNeeded() {
guard session == nil else { return }
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
drillMode: Self.drillMode
)
if let group = store.activeGroup() {
let stored = group.entries
if !stored.isEmpty {
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
guard let lex = byId[e.lexemeId] else { return nil }
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
}
if entries.count == stored.count {
session = LexemeSessionQueue(
entries: entries,
drillMode: Self.drillMode,
learnedCount: group.learnedCount
)
return
switch kind {
case .reviewLearned:
// Cram pass over previously-studied lexemes. No study-group
// persistence restart-fresh each time it opens.
let lexemes = LexemePool.reviewLearnedLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
return
case .standard:
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
drillMode: Self.drillMode
)
if let group = store.activeGroup() {
let stored = group.entries
if !stored.isEmpty {
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
guard let lex = byId[e.lexemeId] else { return nil }
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
}
if entries.count == stored.count {
session = LexemeSessionQueue(
entries: entries,
drillMode: Self.drillMode,
learnedCount: group.learnedCount
)
return
}
}
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
@@ -269,7 +289,8 @@ struct NounFlashcardPracticeView: View {
}
private func persistGroup() {
guard let session else { return }
// Review Learned is a transient cram; don't write a study group.
guard kind == .standard, let session else { return }
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
@@ -286,21 +307,26 @@ struct NounFlashcardPracticeView: View {
}
private func studyAgain() {
LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
drillMode: Self.drillMode
).clear()
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
switch kind {
case .reviewLearned:
session?.restart()
case .standard:
LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
drillMode: Self.drillMode
).clear()
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
revealed = false
persistGroup()
}
}
@@ -0,0 +1,255 @@
import SwiftUI
import SharedModels
import SwiftData
/// English-first noun multiple choice non-verb analog of
/// `VocabMultipleChoicePracticeView`. Driven by `LexemeSessionQueue` over the
/// noun pool; 4 options (1 correct + 3 random distractors from the session).
/// After answering: reveal feedback, the answer with its article (la taza /
/// el problema), example sentence when present, and Again/Hard/Good/Easy
/// rating which drives the `LexemeReviewStore` schedule.
struct NounMultipleChoicePracticeView: View {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var session: LexemeSessionQueue?
@State private var distractorPool: [Lexeme] = []
@State private var options: [Lexeme] = []
@State private var selectedOption: Lexeme? = nil
private static let drillMode = "recall"
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentLexeme: Lexeme? { session?.current?.lexeme }
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let lexeme = currentLexeme {
questionBody(lexeme)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle("Noun Multiple Choice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id)
.animation(.smooth, value: currentLexeme?.id)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0).tint(.teal)
Text(progressLabel).font(.caption).foregroundStyle(.secondary)
}
}
private var progressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Question
@ViewBuilder
private func questionBody(_ lexeme: Lexeme) -> some View {
Text(lexeme.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if selectedOption == nil {
optionGrid
} else {
revealedContent(lexeme)
}
}
private var optionGrid: some View {
VStack(spacing: 10) {
ForEach(options, id: \.id) { option in
Button {
selectedOption = option
} label: {
Text(formattedSpanish(option))
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.tint(.primary)
}
}
}
private func revealedContent(_ lexeme: Lexeme) -> some View {
VStack(spacing: 16) {
answerFeedback(lexeme)
exampleBlock(for: lexeme)
ratingButtons
}
}
private func answerFeedback(_ lexeme: Lexeme) -> some View {
let correct = (selectedOption?.id == lexeme.id)
return VStack(spacing: 6) {
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 36))
.foregroundStyle(correct ? .green : .red)
Text(correct ? "Correct!" : "Not quite")
.font(.headline)
.foregroundStyle(correct ? .green : .red)
Text(formattedSpanish(lexeme))
.font(.title2.weight(.semibold))
.padding(.top, 4)
}
}
@ViewBuilder
private func exampleBlock(for lexeme: Lexeme) -> some View {
if let es = lexeme.exampleES, !es.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(es).font(.subheadline).italic()
if let en = lexeme.exampleEN, !en.isEmpty {
Text(en).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
private var ratingButtons: some View {
VStack(spacing: 10) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, rating: .again)
ratingButton("Hard", color: .orange, rating: .hard)
ratingButton("Good", color: .green, rating: .good)
ratingButton("Easy", color: .blue, rating: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating) -> some View {
Button {
answer(rating)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Completion
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
.font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button { studyAgain() } label: {
Label("Study Again", systemImage: "arrow.clockwise")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.teal)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 { return "\(learned) noun\(learned == 1 ? "" : "s") learned" }
return "No nouns are due right now. Study Again to review anyway."
}
// MARK: - Logic
private func loadIfNeeded() {
guard session == nil else { return }
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
distractorPool = lexemes
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
prepareOptions()
}
private func studyAgain() {
session?.restart()
selectedOption = nil
prepareOptions()
}
private func prepareOptions() {
guard let lexeme = currentLexeme else { options = []; return }
let candidates = distractorPool.filter { $0.id != lexeme.id }
let distractors = Array(candidates.shuffled().prefix(3))
options = ([lexeme] + distractors).shuffled()
}
private func answer(_ rating: LexemeSessionQueue.Rating) {
guard let lexeme = currentLexeme else { return }
let graduation = session?.answer(rating)
if let graduation {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "noun",
drillMode: Self.drillMode,
quality: graduation
)
}
selectedOption = nil
prepareOptions()
}
private func formattedSpanish(_ lexeme: Lexeme) -> String {
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)"
}
}