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 */; };
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user