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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,6 +211,18 @@ struct AdjectiveFlashcardPracticeView: View {
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { 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",
|
||||
@@ -243,6 +257,7 @@ struct AdjectiveFlashcardPracticeView: View {
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
persistGroup()
|
||||
}
|
||||
}
|
||||
|
||||
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
|
||||
let descriptor = FetchDescriptor<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,6 +285,10 @@ struct AdjectiveFlashcardPracticeView: View {
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
switch kind {
|
||||
case .reviewLearned:
|
||||
session?.restart()
|
||||
case .standard:
|
||||
LexemeStudyGroupStore(
|
||||
context: cloudContext,
|
||||
partOfSpeech: "adjective",
|
||||
@@ -284,7 +303,8 @@ struct AdjectiveFlashcardPracticeView: View {
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
revealed = false
|
||||
persistGroup()
|
||||
}
|
||||
revealed = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +230,20 @@ struct NounFlashcardPracticeView: View {
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { 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",
|
||||
@@ -259,6 +278,7 @@ struct NounFlashcardPracticeView: View {
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
persistGroup()
|
||||
}
|
||||
}
|
||||
|
||||
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
|
||||
let descriptor = FetchDescriptor<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,6 +307,10 @@ struct NounFlashcardPracticeView: View {
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
switch kind {
|
||||
case .reviewLearned:
|
||||
session?.restart()
|
||||
case .standard:
|
||||
LexemeStudyGroupStore(
|
||||
context: cloudContext,
|
||||
partOfSpeech: "noun",
|
||||
@@ -300,7 +325,8 @@ struct NounFlashcardPracticeView: View {
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
revealed = false
|
||||
persistGroup()
|
||||
}
|
||||
revealed = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user