Add Extra Study — star cards during review, study just the marked ones
Per-week "Extra Study (N)" row appears at the end of each LanGo week section when at least one card is marked. Cards are marked from inside VocabFlashcardView via a star next to the speaker on reveal. Marks are keyed by the same SHA256 hash CourseReviewCard uses, so a mark and its SRS state describe the same logical card. ExtraStudyMark is CloudKit-synced (private DB), with uniqueness enforced by fetch-or-create on id since CloudKit forbids @Attribute(.unique). Skipped for textbook courses: DeckStudyView nils out the mark context when the deck's courseName matches a TextbookChapter, and CourseView hides the row when the active course is a textbook — so there are no orphan marks the user can't reach. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@
|
||||
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 */; };
|
||||
6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */; };
|
||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
|
||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
|
||||
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.swift */; };
|
||||
@@ -78,6 +79,7 @@
|
||||
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
|
||||
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
|
||||
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; };
|
||||
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */; };
|
||||
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; };
|
||||
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
|
||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
|
||||
@@ -160,6 +162,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>"; };
|
||||
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.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>"; };
|
||||
@@ -212,6 +215,7 @@
|
||||
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
|
||||
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActionsView.swift; sourceTree = "<group>"; };
|
||||
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
|
||||
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyView.swift; sourceTree = "<group>"; };
|
||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
|
||||
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
|
||||
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = "<group>"; };
|
||||
@@ -339,6 +343,7 @@
|
||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
|
||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
||||
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
|
||||
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -515,6 +520,7 @@
|
||||
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
|
||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
||||
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */,
|
||||
);
|
||||
path = Course;
|
||||
sourceTree = "<group>";
|
||||
@@ -733,6 +739,8 @@
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
|
||||
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */,
|
||||
6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -72,14 +72,14 @@ struct ConjugaApp: App {
|
||||
schema: Schema([
|
||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self,
|
||||
]),
|
||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||
)
|
||||
cloudContainer = try ModelContainer(
|
||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self,
|
||||
configurations: cloudConfig
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Cloud-context CRUD for `ExtraStudyMark`. Uniqueness is enforced in code via
|
||||
/// fetch-or-create on `id` (CloudKit forbids `@Attribute(.unique)`).
|
||||
struct ExtraStudyStore {
|
||||
let context: ModelContext
|
||||
|
||||
private func fetchMark(id: String) -> ExtraStudyMark? {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> { $0.id == id }
|
||||
)
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
}
|
||||
|
||||
func contains(card: VocabCard) -> Bool {
|
||||
fetchMark(id: CourseCardStore.reviewKey(for: card)) != nil
|
||||
}
|
||||
|
||||
/// Toggle a mark for the given card. Returns the new "is marked" state.
|
||||
@discardableResult
|
||||
func toggle(
|
||||
card: VocabCard,
|
||||
courseName: String,
|
||||
weekNumber: Int
|
||||
) -> Bool {
|
||||
let id = CourseCardStore.reviewKey(for: card)
|
||||
if let existing = fetchMark(id: id) {
|
||||
context.delete(existing)
|
||||
try? context.save()
|
||||
return false
|
||||
}
|
||||
let mark = ExtraStudyMark(
|
||||
id: id,
|
||||
deckId: card.deckId,
|
||||
courseName: courseName,
|
||||
weekNumber: weekNumber,
|
||||
front: card.front,
|
||||
back: card.back
|
||||
)
|
||||
context.insert(mark)
|
||||
try? context.save()
|
||||
return true
|
||||
}
|
||||
|
||||
func count(courseName: String, weekNumber: Int) -> Int {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> {
|
||||
$0.courseName == courseName && $0.weekNumber == weekNumber
|
||||
}
|
||||
)
|
||||
return (try? context.fetchCount(descriptor)) ?? 0
|
||||
}
|
||||
|
||||
func countsByWeek(courseName: String) -> [Int: Int] {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> { $0.courseName == courseName }
|
||||
)
|
||||
let marks = (try? context.fetch(descriptor)) ?? []
|
||||
var counts: [Int: Int] = [:]
|
||||
for mark in marks {
|
||||
counts[mark.weekNumber, default: 0] += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func fetch(courseName: String, weekNumber: Int) -> [ExtraStudyMark] {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> {
|
||||
$0.courseName == courseName && $0.weekNumber == weekNumber
|
||||
},
|
||||
sortBy: [SortDescriptor(\.markedAt)]
|
||||
)
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
func fetchIds(courseName: String, weekNumber: Int) -> Set<String> {
|
||||
Set(fetch(courseName: courseName, weekNumber: weekNumber).map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight context passed to `VocabFlashcardView` so the in-session star
|
||||
/// button knows which week/course to attribute a mark to.
|
||||
struct ExtraStudyMarkContext: Equatable {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
}
|
||||
@@ -8,11 +8,16 @@ struct CourseView: View {
|
||||
@Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter]
|
||||
@AppStorage("selectedCourse") private var selectedCourse: String?
|
||||
@State private var testResults: [TestResult] = []
|
||||
@State private var extraStudyCounts: [Int: Int] = [:]
|
||||
|
||||
private var textbookCourses: [String] {
|
||||
Array(Set(textbookChapters.map(\.courseName))).sorted()
|
||||
}
|
||||
|
||||
private var activeCourseIsTextbook: Bool {
|
||||
textbookCourses.contains(activeCourse)
|
||||
}
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var courseNames: [String] {
|
||||
@@ -169,6 +174,28 @@ struct CourseView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extra Study row — only when there are marks for this week
|
||||
if !activeCourseIsTextbook, let markCount = extraStudyCounts[week], markCount > 0 {
|
||||
NavigationLink(value: ExtraStudyDestination(courseName: activeCourse, weekNumber: week)) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.yellow)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Extra Study")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("\(markCount) marked card\(markCount == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Week \(week)")
|
||||
}
|
||||
@@ -176,10 +203,19 @@ struct CourseView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
|
||||
.onAppear(perform: loadTestResults)
|
||||
.onAppear {
|
||||
loadTestResults()
|
||||
loadExtraStudyCounts()
|
||||
}
|
||||
.onChange(of: activeCourse) { _, _ in
|
||||
loadExtraStudyCounts()
|
||||
}
|
||||
.navigationDestination(for: CourseDeck.self) { deck in
|
||||
DeckStudyView(deck: deck)
|
||||
}
|
||||
.navigationDestination(for: ExtraStudyDestination.self) { dest in
|
||||
ExtraStudyView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||
}
|
||||
.navigationDestination(for: WeekTestDestination.self) { dest in
|
||||
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||
}
|
||||
@@ -210,6 +246,12 @@ struct CourseView: View {
|
||||
private func loadTestResults() {
|
||||
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
|
||||
}
|
||||
|
||||
private func loadExtraStudyCounts() {
|
||||
guard !activeCourse.isEmpty else { return }
|
||||
extraStudyCounts = ExtraStudyStore(context: cloudModelContext)
|
||||
.countsByWeek(courseName: activeCourse)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
@@ -228,6 +270,11 @@ struct TextbookDestination: Hashable {
|
||||
let courseName: String
|
||||
}
|
||||
|
||||
struct ExtraStudyDestination: Hashable {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
}
|
||||
|
||||
// MARK: - Deck Row
|
||||
|
||||
private struct DeckRowView: View {
|
||||
|
||||
@@ -6,6 +6,7 @@ struct DeckStudyView: View {
|
||||
let deck: CourseDeck
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query private var textbookChapters: [TextbookChapter]
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var isStudying = false
|
||||
@State private var speechService = SpeechService()
|
||||
@@ -16,6 +17,18 @@ struct DeckStudyView: View {
|
||||
deck.title.localizedCaseInsensitiveContains("stem changing")
|
||||
}
|
||||
|
||||
private var isTextbookDeck: Bool {
|
||||
textbookChapters.contains { $0.courseName == deck.courseName }
|
||||
}
|
||||
|
||||
private var markContext: ExtraStudyMarkContext? {
|
||||
guard !isTextbookDeck else { return nil }
|
||||
return ExtraStudyMarkContext(
|
||||
courseName: deck.courseName,
|
||||
weekNumber: deck.weekNumber
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
cardListView
|
||||
.navigationTitle(deck.title)
|
||||
@@ -30,7 +43,8 @@ struct DeckStudyView: View {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
isStudying = false
|
||||
},
|
||||
deckTitle: deck.title
|
||||
deckTitle: deck.title,
|
||||
markContext: markContext
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Study session containing only cards the user has marked for extra study,
|
||||
/// scoped to a specific (courseName, weekNumber). Resolves marks by re-hashing
|
||||
/// each VocabCard via `CourseCardStore.reviewKey` so the matching is robust to
|
||||
/// duplicate (deckId, front, back) tuples that differ in examples.
|
||||
struct ExtraStudyView: View {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var cards: [VocabCard] = []
|
||||
@State private var speechService = SpeechService()
|
||||
@State private var loaded = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !loaded {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
} else if cards.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Marked Cards",
|
||||
systemImage: "star",
|
||||
description: Text("Tap the star on a card during study to add it here.")
|
||||
)
|
||||
} else {
|
||||
VocabFlashcardView(
|
||||
cards: cards.shuffled(),
|
||||
speechService: speechService,
|
||||
onDone: {
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
dismiss()
|
||||
},
|
||||
deckTitle: "Extra Study",
|
||||
markContext: ExtraStudyMarkContext(
|
||||
courseName: courseName,
|
||||
weekNumber: weekNumber
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Extra Study · Week \(weekNumber)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { load() }
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard !loaded else { return }
|
||||
let marks = ExtraStudyStore(context: cloudContext)
|
||||
.fetch(courseName: courseName, weekNumber: weekNumber)
|
||||
let markIds = Set(marks.map(\.id))
|
||||
let deckIds = Set(marks.map(\.deckId))
|
||||
|
||||
var collected: [VocabCard] = []
|
||||
for deckId in deckIds {
|
||||
let descriptor = FetchDescriptor<VocabCard>(
|
||||
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
|
||||
)
|
||||
let deckCards = (try? localContext.fetch(descriptor)) ?? []
|
||||
collected.append(contentsOf: deckCards.filter {
|
||||
markIds.contains(CourseCardStore.reviewKey(for: $0))
|
||||
})
|
||||
}
|
||||
cards = collected
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,16 @@ struct VocabFlashcardView: View {
|
||||
/// Optional deck context — when present and the title indicates a stem-
|
||||
/// changing deck, each card gets an inline conjugation toggle.
|
||||
var deckTitle: String? = nil
|
||||
/// When set, a star button appears next to the speaker on reveal so the
|
||||
/// user can mark the card for extra study.
|
||||
var markContext: ExtraStudyMarkContext? = nil
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var currentIndex = 0
|
||||
@State private var isRevealed = false
|
||||
@State private var sessionCorrect = 0
|
||||
@State private var showConjugation = false
|
||||
@State private var markedIds: Set<String> = []
|
||||
|
||||
private var isStemChangingDeck: Bool {
|
||||
(deckTitle ?? "").localizedCaseInsensitiveContains("stem changing")
|
||||
@@ -61,6 +65,7 @@ struct VocabFlashcardView: View {
|
||||
.font(.title.weight(.medium))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
speechService.speak(card.front)
|
||||
} label: {
|
||||
@@ -70,6 +75,20 @@ struct VocabFlashcardView: View {
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
|
||||
if markContext != nil {
|
||||
Button {
|
||||
toggleMark()
|
||||
} label: {
|
||||
Image(systemName: currentIsMarked ? "star.fill" : "star")
|
||||
.font(.title3)
|
||||
.padding(12)
|
||||
.foregroundStyle(currentIsMarked ? .yellow : .secondary)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
.accessibilityLabel(currentIsMarked ? "Unmark for extra study" : "Mark for extra study")
|
||||
}
|
||||
}
|
||||
|
||||
if isStemChangingDeck {
|
||||
Button {
|
||||
withAnimation(.smooth) { showConjugation.toggle() }
|
||||
@@ -194,6 +213,34 @@ struct VocabFlashcardView: View {
|
||||
}
|
||||
.animation(.smooth, value: isRevealed)
|
||||
.animation(.smooth, value: currentIndex)
|
||||
.task { loadMarks() }
|
||||
}
|
||||
|
||||
private var currentIsMarked: Bool {
|
||||
guard let card = currentCard else { return false }
|
||||
return markedIds.contains(CourseCardStore.reviewKey(for: card))
|
||||
}
|
||||
|
||||
private func loadMarks() {
|
||||
guard let ctx = markContext else { return }
|
||||
markedIds = ExtraStudyStore(context: cloudModelContext)
|
||||
.fetchIds(courseName: ctx.courseName, weekNumber: ctx.weekNumber)
|
||||
}
|
||||
|
||||
private func toggleMark() {
|
||||
guard let card = currentCard, let ctx = markContext else { return }
|
||||
let store = ExtraStudyStore(context: cloudModelContext)
|
||||
let isNowMarked = store.toggle(
|
||||
card: card,
|
||||
courseName: ctx.courseName,
|
||||
weekNumber: ctx.weekNumber
|
||||
)
|
||||
let key = CourseCardStore.reviewKey(for: card)
|
||||
if isNowMarked {
|
||||
markedIds.insert(key)
|
||||
} else {
|
||||
markedIds.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// User mark on a vocab card for "extra study" focus. Cloud-synced so the set
|
||||
/// follows the user across devices. Denormalises card identity (deckId/front/
|
||||
/// back) so the Course tab can resolve a marked-cards view without joining
|
||||
/// against the local-only `VocabCard` store.
|
||||
///
|
||||
/// CloudKit forbids `@Attribute(.unique)`, so callers must fetch-or-create
|
||||
/// by `id` to maintain uniqueness in code.
|
||||
@Model
|
||||
public final class ExtraStudyMark {
|
||||
/// Stable hash of (deckId, front, back, examplesES, examplesEN). Same
|
||||
/// shape as `CourseCardStore.reviewKey(for:)` so a mark and a review
|
||||
/// card describe the same logical card.
|
||||
public var id: String = ""
|
||||
|
||||
public var deckId: String = ""
|
||||
public var courseName: String = ""
|
||||
public var weekNumber: Int = 0
|
||||
|
||||
public var front: String = ""
|
||||
public var back: String = ""
|
||||
|
||||
public var markedAt: Date = Date()
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
deckId: String,
|
||||
courseName: String,
|
||||
weekNumber: Int,
|
||||
front: String,
|
||||
back: String,
|
||||
markedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.deckId = deckId
|
||||
self.courseName = courseName
|
||||
self.weekNumber = weekNumber
|
||||
self.front = front
|
||||
self.back = back
|
||||
self.markedAt = markedAt
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user