Vocab Flashcards — persist the study group across launches and devices

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) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-17 15:42:17 -05:00
parent eec0fb56d5
commit c794c013f0
5 changed files with 172 additions and 10 deletions
@@ -57,6 +57,7 @@
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; }; 615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD4AF96186662567525F8C4 /* BookReaderView.swift */; }; 64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD4AF96186662567525F8C4 /* BookReaderView.swift */; };
65382875879BD537F5358381 /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.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 */; }; 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; }; 6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.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 = "<group>"; }; DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; }; DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; }; DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabStudyGroup.swift; sourceTree = "<group>"; };
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.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>"; }; E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; }; E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
@@ -405,6 +407,7 @@
DAFE27F29412021AEC57E728 /* TestResult.swift */, DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */, E536AD1180FE10576EAC884A /* UserProgress.swift */,
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */, A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -803,6 +806,7 @@
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */, 12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */, 5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */, 13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */,
6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+2 -2
View File
@@ -72,14 +72,14 @@ struct ConjugaApp: App {
schema: Schema([ schema: Schema([
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self, ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.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") cloudKitDatabase: .private("iCloud.com.conjuga.app")
) )
cloudContainer = try ModelContainer( cloudContainer = try ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self, for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self, TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, ExtraStudyMark.self, TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
configurations: cloudConfig configurations: cloudConfig
) )
@@ -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<VocabStudyGroup>(
predicate: #Predicate<VocabStudyGroup> { $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<VocabStudyGroup>(
predicate: #Predicate<VocabStudyGroup> { $0.id == id }
)
for group in (try? context.fetch(descriptor)) ?? [] {
context.delete(group)
}
try? context.save()
}
}
@@ -19,7 +19,7 @@ import SwiftData
/// Again/Hard presses don't touch the cross-session schedule. /// Again/Hard presses don't touch the cross-session schedule.
struct VocabSessionQueue { struct VocabSessionQueue {
enum CardState { enum CardState: String {
case new // never answered this session case new // never answered this session
case learning // answered Again/Hard at least once case learning // answered Again/Hard at least once
case review // answered Good once, one confirmation pass to go case review // answered Good once, one confirmation pass to go
@@ -44,6 +44,20 @@ struct VocabSessionQueue {
queue = verbs.map { Entry(verb: $0, state: .new) } 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 // MARK: - State
var current: Entry? { queue.first } var current: Entry? { queue.first }
@@ -239,10 +239,13 @@ struct VocabFlashcardPracticeView: View {
Button { Button {
studyAgain() studyAgain()
} label: { } label: {
Label("Study Again", systemImage: "arrow.clockwise") Label(
.font(.subheadline.weight(.semibold)) kind == .reviewLearned ? "Study Again" : "Next Set",
.padding(.vertical, 6) systemImage: kind == .reviewLearned ? "arrow.clockwise" : "arrow.right"
.frame(maxWidth: .infinity) )
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(.purple) .tint(.purple)
@@ -386,16 +389,76 @@ struct VocabFlashcardPracticeView: View {
guard sessionVerbs.isEmpty else { return } guard sessionVerbs.isEmpty else { return }
switch kind { switch kind {
case .standard: case .standard:
sessionVerbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) loadStandardSession()
case .reviewLearned: case .reviewLearned:
sessionVerbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext) sessionVerbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: sessionVerbs)
} }
session = VocabSessionQueue(verbs: sessionVerbs)
primeExampleForCurrent() 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>) -> [Int: Verb] {
let all = (try? localContext.fetch(FetchDescriptor<Verb>())) ?? []
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() { 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 revealed = false
primeExampleForCurrent() primeExampleForCurrent()
} }
@@ -408,6 +471,7 @@ struct VocabFlashcardPracticeView: View {
if let graduation, kind == .standard { if let graduation, kind == .standard {
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation) VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
} }
persistGroup()
withAnimation(.smooth) { revealed = false } withAnimation(.smooth) { revealed = false }
primeExampleForCurrent() primeExampleForCurrent()
} }