From c794c013f0cdde20b4398aaaae9ea2909f184c85 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sun, 17 May 2026 15:42:17 -0500 Subject: [PATCH] =?UTF-8?q?Vocab=20Flashcards=20=E2=80=94=20persist=20the?= =?UTF-8?q?=20study=20group=20across=20launches=20and=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session queue was in-memory only: leaving the flashcard screen or killing the app rebuilt it from scratch. The set of verbs you were working through wasn't sticky. VocabStudyGroup (new cloud-synced @Model) — the active standard study set: the in-session queue (un-graduated verbs + each card's learning-step state) as a JSON blob, plus the learned count. One active group at a time, keyed "active-standard". CloudKit-synced, so the same group follows you from iPad to iPhone. VocabStudyGroupStore — fetch-or-create / persist / clear. activeGroup() returns the newest if duplicates exist (two devices racing before sync settles); clear() removes all duplicates. VocabSessionQueue — CardState is now String-backed; added init(entries:learnedCount:) to resume a saved queue and snapshot() to export it. VocabFlashcardPracticeView (standard kind): - On load, resume the active group if one exists; otherwise build a fresh set and persist it immediately. - Every answer writes the updated queue to the group. - Finishing the set clears the group; the completion button is now "Next Set" and builds a fresh group. Review Learned (reviewLearned kind) is unaffected — it's an ad-hoc cram and intentionally isn't persisted; its button stays "Study Again". Registered VocabStudyGroup in the cloud container schema. Co-Authored-By: Claude Opus 4.7 (1M context) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 4 + Conjuga/Conjuga/ConjugaApp.swift | 4 +- Conjuga/Conjuga/Models/VocabStudyGroup.swift | 80 +++++++++++++++++++ .../Conjuga/Services/VocabSessionQueue.swift | 16 +++- .../Vocab/VocabFlashcardPracticeView.swift | 78 ++++++++++++++++-- 5 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 Conjuga/Conjuga/Models/VocabStudyGroup.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 8b42897..ba7c113 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -57,6 +57,7 @@ 615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; }; 64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD4AF96186662567525F8C4 /* BookReaderView.swift */; }; 65382875879BD537F5358381 /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */; }; + 6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */; }; 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; }; 6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; }; 6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; }; @@ -258,6 +259,7 @@ DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = ""; }; DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = ""; }; DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = ""; }; + DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabStudyGroup.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 = ""; }; E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = ""; }; @@ -405,6 +407,7 @@ DAFE27F29412021AEC57E728 /* TestResult.swift */, E536AD1180FE10576EAC884A /* UserProgress.swift */, A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */, + DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */, ); path = Models; sourceTree = ""; @@ -803,6 +806,7 @@ 12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */, 5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */, 13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */, + 6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index 1062a72..18c9638 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -72,14 +72,14 @@ struct ConjugaApp: App { schema: Schema([ ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self, TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self, - TextbookExerciseAttempt.self, ExtraStudyMark.self, + TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self, ]), cloudKitDatabase: .private("iCloud.com.conjuga.app") ) cloudContainer = try ModelContainer( for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self, TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self, - TextbookExerciseAttempt.self, ExtraStudyMark.self, + TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self, configurations: cloudConfig ) diff --git a/Conjuga/Conjuga/Models/VocabStudyGroup.swift b/Conjuga/Conjuga/Models/VocabStudyGroup.swift new file mode 100644 index 0000000..dd6bc7e --- /dev/null +++ b/Conjuga/Conjuga/Models/VocabStudyGroup.swift @@ -0,0 +1,80 @@ +import Foundation +import SwiftData + +/// The user's active vocab-flashcard study set, persisted and CloudKit-synced +/// so the same group of verbs follows them across launches and across devices. +/// A new group is created when the previous one is fully learned. +/// +/// CloudKit-synced; uniqueness on `id` is enforced in code (CloudKit forbids +/// `@Attribute(.unique)`). There is one active standard group at a time. +@Model +final class VocabStudyGroup { + var id: String = "" + /// JSON-encoded `[StoredVocabEntry]` — the in-session queue (un-graduated + /// verbs only) in order, each with its learning-step state. + var entriesJSON: Data = Data() + var learnedCount: Int = 0 + var createdAt: Date = Date() + + init(entriesJSON: Data, learnedCount: Int) { + self.id = Self.activeID + self.entriesJSON = entriesJSON + self.learnedCount = learnedCount + self.createdAt = Date() + } + + /// Single active standard-session group. + static let activeID = "active-standard" + + var entries: [StoredVocabEntry] { + (try? JSONDecoder().decode([StoredVocabEntry].self, from: entriesJSON)) ?? [] + } +} + +/// One verb's persisted spot in the study group. +struct StoredVocabEntry: Codable { + var verbId: Int + /// Raw value of `VocabSessionQueue.CardState`. + var state: String +} + +/// Fetch / persist / clear the active study group. Operates on the cloud +/// context so the group syncs across devices. +struct VocabStudyGroupStore { + let context: ModelContext + + /// The current active group, or nil. If duplicate records exist (two + /// devices both created one before sync settled), the newest wins. + func activeGroup() -> VocabStudyGroup? { + let id = VocabStudyGroup.activeID + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id }, + sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)] + ) + return (try? context.fetch(descriptor))?.first + } + + /// Write the in-progress group, creating it if needed. + func persist(entries: [StoredVocabEntry], learnedCount: Int) { + let data = (try? JSONEncoder().encode(entries)) ?? Data() + if let group = activeGroup() { + group.entriesJSON = data + group.learnedCount = learnedCount + } else { + context.insert(VocabStudyGroup(entriesJSON: data, learnedCount: learnedCount)) + } + try? context.save() + } + + /// Remove the active group (and any duplicates) — the set is finished. + func clear() { + let id = VocabStudyGroup.activeID + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + for group in (try? context.fetch(descriptor)) ?? [] { + context.delete(group) + } + try? context.save() + } +} diff --git a/Conjuga/Conjuga/Services/VocabSessionQueue.swift b/Conjuga/Conjuga/Services/VocabSessionQueue.swift index 656503d..82654df 100644 --- a/Conjuga/Conjuga/Services/VocabSessionQueue.swift +++ b/Conjuga/Conjuga/Services/VocabSessionQueue.swift @@ -19,7 +19,7 @@ import SwiftData /// Again/Hard presses don't touch the cross-session schedule. struct VocabSessionQueue { - enum CardState { + enum CardState: String { case new // never answered this session case learning // answered Again/Hard at least once case review // answered Good once, one confirmation pass to go @@ -44,6 +44,20 @@ struct VocabSessionQueue { queue = verbs.map { Entry(verb: $0, state: .new) } } + /// Resume a persisted group: rebuild the queue from saved (verb, state) + /// pairs in order, restoring the learned count. The exact requeue + /// positions aren't persisted — the queue order itself is what's saved. + init(entries: [(verb: Verb, state: CardState)], learnedCount: Int) { + originalVerbs = entries.map(\.verb) + queue = entries.map { Entry(verb: $0.verb, state: $0.state) } + self.learnedCount = learnedCount + } + + /// Current queue (un-graduated cards) in order — for persistence. + func snapshot() -> [(verbId: Int, state: CardState)] { + queue.map { ($0.verb.id, $0.state) } + } + // MARK: - State var current: Entry? { queue.first } diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift index 4bcded5..7b0ee20 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift @@ -239,10 +239,13 @@ struct VocabFlashcardPracticeView: View { Button { studyAgain() } label: { - Label("Study Again", systemImage: "arrow.clockwise") - .font(.subheadline.weight(.semibold)) - .padding(.vertical, 6) - .frame(maxWidth: .infinity) + Label( + kind == .reviewLearned ? "Study Again" : "Next Set", + systemImage: kind == .reviewLearned ? "arrow.clockwise" : "arrow.right" + ) + .font(.subheadline.weight(.semibold)) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(.purple) @@ -386,16 +389,76 @@ struct VocabFlashcardPracticeView: View { guard sessionVerbs.isEmpty else { return } switch kind { case .standard: - sessionVerbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) + loadStandardSession() case .reviewLearned: sessionVerbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext) + session = VocabSessionQueue(verbs: sessionVerbs) } - session = VocabSessionQueue(verbs: sessionVerbs) primeExampleForCurrent() } + /// Resume the persisted, cross-device study group if one is active; + /// otherwise start a fresh group and persist it. + private func loadStandardSession() { + let store = VocabStudyGroupStore(context: cloudContext) + if let group = store.activeGroup() { + let stored = group.entries + if !stored.isEmpty { + let byId = verbsByID(Set(stored.map(\.verbId))) + let entries: [(verb: Verb, state: VocabSessionQueue.CardState)] = stored.compactMap { e in + guard let verb = byId[e.verbId] else { return nil } + return (verb, VocabSessionQueue.CardState(rawValue: e.state) ?? .new) + } + if !entries.isEmpty { + sessionVerbs = entries.map(\.verb) + session = VocabSessionQueue(entries: entries, learnedCount: group.learnedCount) + return + } + } + } + // No active group — start fresh and persist immediately so closing + // the app right away still resumes this set. + let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) + sessionVerbs = verbs + session = VocabSessionQueue(verbs: verbs) + persistGroup() + } + + private func verbsByID(_ ids: Set) -> [Int: Verb] { + let all = (try? localContext.fetch(FetchDescriptor())) ?? [] + var map: [Int: Verb] = [:] + for verb in all where ids.contains(verb.id) { map[verb.id] = verb } + return map + } + + /// Write the standard session's progress to the cloud-synced study group, + /// or clear the group when the set is fully learned. + private func persistGroup() { + guard kind == .standard, let session else { return } + let store = VocabStudyGroupStore(context: cloudContext) + if session.isComplete { + store.clear() + } else { + let entries = session.snapshot().map { + StoredVocabEntry(verbId: $0.verbId, state: $0.state.rawValue) + } + store.persist(entries: entries, learnedCount: session.learnedCount) + } + } + private func studyAgain() { - session?.restart() + switch kind { + case .standard: + // The finished group was already cleared by persistGroup(); + // build and persist a fresh set. + let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) + sessionVerbs = verbs + session = VocabSessionQueue(verbs: verbs) + persistGroup() + case .reviewLearned: + session?.restart() + } + learnIndex = 0 revealed = false primeExampleForCurrent() } @@ -408,6 +471,7 @@ struct VocabFlashcardPracticeView: View { if let graduation, kind == .standard { VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation) } + persistGroup() withAnimation(.smooth) { revealed = false } primeExampleForCurrent() }