From 696eafa64f6daaa99a4fdfd2bea0169c4ae79da2 Mon Sep 17 00:00:00 2001 From: Trey T Date: Tue, 19 May 2026 20:59:42 -0500 Subject: [PATCH] =?UTF-8?q?Noun=20&=20adjective=20practice=20=E2=80=94=20M?= =?UTF-8?q?ultiple=20Choice,=20Review=20Learned,=20Review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 16 ++ .../Conjuga/Services/LexemeSessionQueue.swift | 12 + .../Views/Practice/AdjectiveReviewView.swift | 185 +++++++++++++ .../Views/Practice/NounReviewView.swift | 197 ++++++++++++++ .../Conjuga/Views/Practice/PracticeView.swift | 237 +++++++++++----- .../AdjectiveFlashcardPracticeView.swift | 116 ++++---- .../AdjectiveMultipleChoicePracticeView.swift | 234 ++++++++++++++++ .../Vocab/NounFlashcardPracticeView.swift | 122 +++++---- .../NounMultipleChoicePracticeView.swift | 255 ++++++++++++++++++ 9 files changed, 1215 insertions(+), 159 deletions(-) create mode 100644 Conjuga/Conjuga/Views/Practice/AdjectiveReviewView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/NounReviewView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveMultipleChoicePracticeView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Vocab/NounMultipleChoicePracticeView.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 6d127b2..bd605aa 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -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 = ""; }; 03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "book_olly-vol2.json"; sourceTree = ""; }; 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = ""; }; + 09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveReviewView.swift; sourceTree = ""; }; 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = ""; }; + 0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounMultipleChoicePracticeView.swift; sourceTree = ""; }; 102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = ""; }; 10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = ""; }; 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = ""; }; @@ -182,6 +188,7 @@ 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = ""; }; 1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = ""; }; 20D1904DF07E0A6816134CF3 /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = ""; }; + 256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveMultipleChoicePracticeView.swift; sourceTree = ""; }; 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = ""; }; 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = ""; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = ""; }; @@ -276,6 +283,7 @@ DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = ""; }; E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = ""; }; E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = ""; }; + E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounReviewView.swift; sourceTree = ""; }; E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = ""; }; E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = ""; }; E8D95887B18216FCA71643D6 /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Conjuga/Conjuga/Services/LexemeSessionQueue.swift b/Conjuga/Conjuga/Services/LexemeSessionQueue.swift index eed854d..08bbe88 100644 --- a/Conjuga/Conjuga/Services/LexemeSessionQueue.swift +++ b/Conjuga/Conjuga/Services/LexemeSessionQueue.swift @@ -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 diff --git a/Conjuga/Conjuga/Views/Practice/AdjectiveReviewView.swift b/Conjuga/Conjuga/Views/Practice/AdjectiveReviewView.swift new file mode 100644 index 0000000..946ce3d --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/AdjectiveReviewView.swift @@ -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( + predicate: #Predicate { + $0.partOfSpeech == pos && $0.dueDate <= now + }, + sortBy: [SortDescriptor(\LexemeReviewCard.dueDate)] + ) + dueCards = (try? cloudContext.fetch(descriptor)) ?? [] + + let ids = Set(dueCards.map(\.lexemeId)) + let lexDesc = FetchDescriptor( + predicate: #Predicate { 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( + predicate: #Predicate { + $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 + } +} diff --git a/Conjuga/Conjuga/Views/Practice/NounReviewView.swift b/Conjuga/Conjuga/Views/Practice/NounReviewView.swift new file mode 100644 index 0000000..c7a5784 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/NounReviewView.swift @@ -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( + predicate: #Predicate { + $0.partOfSpeech == pos && $0.dueDate <= now + }, + sortBy: [SortDescriptor(\LexemeReviewCard.dueDate)] + ) + dueCards = (try? cloudContext.fetch(descriptor)) ?? [] + + let ids = Set(dueCards.map(\.lexemeId)) + let lexDesc = FetchDescriptor( + predicate: #Predicate { 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( + predicate: #Predicate { + $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 + } +} diff --git a/Conjuga/Conjuga/Views/Practice/PracticeView.swift b/Conjuga/Conjuga/Views/Practice/PracticeView.swift index 689d361..950fa44 100644 --- a/Conjuga/Conjuga/Views/Practice/PracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/PracticeView.swift @@ -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 diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveFlashcardPracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveFlashcardPracticeView.swift index c8968dc..3b76887 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveFlashcardPracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveFlashcardPracticeView.swift @@ -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: 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() } } diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveMultipleChoicePracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveMultipleChoicePracticeView.swift new file mode 100644 index 0000000..b24e860 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveMultipleChoicePracticeView.swift @@ -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() + } +} diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/NounFlashcardPracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/NounFlashcardPracticeView.swift index 588fc0c..41af767 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/NounFlashcardPracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/NounFlashcardPracticeView.swift @@ -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: 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() } } diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/NounMultipleChoicePracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/NounMultipleChoicePracticeView.swift new file mode 100644 index 0000000..24eb5a3 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Vocab/NounMultipleChoicePracticeView.swift @@ -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)" + } +}