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:
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -405,6 +407,7 @@
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
|
||||
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
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 }
|
||||
|
||||
@@ -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>) -> [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() {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user