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:
Trey T
2026-05-04 22:38:39 -05:00
parent 05a0cc0d17
commit ade091f108
8 changed files with 334 additions and 11 deletions
@@ -51,6 +51,7 @@
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 */; };
6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */; };
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; }; 728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; }; 760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.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 */; }; ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; }; AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.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 */; }; B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; };
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; }; B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = "<group>"; };
@@ -339,6 +343,7 @@
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */, 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */, D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */, AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -515,6 +520,7 @@
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */, 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */, 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */, 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */,
); );
path = Course; path = Course;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -733,6 +739,8 @@
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */, 05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */,
6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.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, UserProgress.self, ReviewCard.self, CourseReviewCard.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, TextbookExerciseAttempt.self, ExtraStudyMark.self,
]), ]),
cloudKitDatabase: .private("iCloud.com.conjuga.app") cloudKitDatabase: .private("iCloud.com.conjuga.app")
) )
cloudContainer = try ModelContainer( cloudContainer = try ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self, for: ReviewCard.self, CourseReviewCard.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, TextbookExerciseAttempt.self, ExtraStudyMark.self,
configurations: cloudConfig 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
}
+48 -1
View File
@@ -8,11 +8,16 @@ struct CourseView: View {
@Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter] @Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter]
@AppStorage("selectedCourse") private var selectedCourse: String? @AppStorage("selectedCourse") private var selectedCourse: String?
@State private var testResults: [TestResult] = [] @State private var testResults: [TestResult] = []
@State private var extraStudyCounts: [Int: Int] = [:]
private var textbookCourses: [String] { private var textbookCourses: [String] {
Array(Set(textbookChapters.map(\.courseName))).sorted() Array(Set(textbookChapters.map(\.courseName))).sorted()
} }
private var activeCourseIsTextbook: Bool {
textbookCourses.contains(activeCourse)
}
private var cloudModelContext: ModelContext { cloudModelContextProvider() } private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var courseNames: [String] { 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: { } header: {
Text("Week \(week)") Text("Week \(week)")
} }
@@ -176,10 +203,19 @@ struct CourseView: View {
} }
} }
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse)) .navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
.onAppear(perform: loadTestResults) .onAppear {
loadTestResults()
loadExtraStudyCounts()
}
.onChange(of: activeCourse) { _, _ in
loadExtraStudyCounts()
}
.navigationDestination(for: CourseDeck.self) { deck in .navigationDestination(for: CourseDeck.self) { deck in
DeckStudyView(deck: deck) DeckStudyView(deck: deck)
} }
.navigationDestination(for: ExtraStudyDestination.self) { dest in
ExtraStudyView(courseName: dest.courseName, weekNumber: dest.weekNumber)
}
.navigationDestination(for: WeekTestDestination.self) { dest in .navigationDestination(for: WeekTestDestination.self) { dest in
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber) WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
} }
@@ -210,6 +246,12 @@ struct CourseView: View {
private func loadTestResults() { private func loadTestResults() {
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? [] testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
} }
private func loadExtraStudyCounts() {
guard !activeCourse.isEmpty else { return }
extraStudyCounts = ExtraStudyStore(context: cloudModelContext)
.countsByWeek(courseName: activeCourse)
}
} }
// MARK: - Navigation // MARK: - Navigation
@@ -228,6 +270,11 @@ struct TextbookDestination: Hashable {
let courseName: String let courseName: String
} }
struct ExtraStudyDestination: Hashable {
let courseName: String
let weekNumber: Int
}
// MARK: - Deck Row // MARK: - Deck Row
private struct DeckRowView: View { private struct DeckRowView: View {
@@ -6,6 +6,7 @@ struct DeckStudyView: View {
let deck: CourseDeck let deck: CourseDeck
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query private var textbookChapters: [TextbookChapter]
private var cloudModelContext: ModelContext { cloudModelContextProvider() } private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var isStudying = false @State private var isStudying = false
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
@@ -16,6 +17,18 @@ struct DeckStudyView: View {
deck.title.localizedCaseInsensitiveContains("stem changing") 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 { var body: some View {
cardListView cardListView
.navigationTitle(deck.title) .navigationTitle(deck.title)
@@ -30,7 +43,8 @@ struct DeckStudyView: View {
ReviewStore.recordActivity(context: cloudModelContext) ReviewStore.recordActivity(context: cloudModelContext)
isStudying = false isStudying = false
}, },
deckTitle: deck.title deckTitle: deck.title,
markContext: markContext
) )
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { 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- /// Optional deck context when present and the title indicates a stem-
/// changing deck, each card gets an inline conjugation toggle. /// changing deck, each card gets an inline conjugation toggle.
var deckTitle: String? = nil 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 @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var currentIndex = 0 @State private var currentIndex = 0
@State private var isRevealed = false @State private var isRevealed = false
@State private var sessionCorrect = 0 @State private var sessionCorrect = 0
@State private var showConjugation = false @State private var showConjugation = false
@State private var markedIds: Set<String> = []
private var isStemChangingDeck: Bool { private var isStemChangingDeck: Bool {
(deckTitle ?? "").localizedCaseInsensitiveContains("stem changing") (deckTitle ?? "").localizedCaseInsensitiveContains("stem changing")
@@ -61,14 +65,29 @@ struct VocabFlashcardView: View {
.font(.title.weight(.medium)) .font(.title.weight(.medium))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button { HStack(spacing: 12) {
speechService.speak(card.front) Button {
} label: { speechService.speak(card.front)
Image(systemName: "speaker.wave.2.fill") } label: {
.font(.title3) Image(systemName: "speaker.wave.2.fill")
.padding(12) .font(.title3)
.padding(12)
}
.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")
}
} }
.glassEffect(in: .circle)
if isStemChangingDeck { if isStemChangingDeck {
Button { Button {
@@ -194,6 +213,34 @@ struct VocabFlashcardView: View {
} }
.animation(.smooth, value: isRevealed) .animation(.smooth, value: isRevealed)
.animation(.smooth, value: currentIndex) .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 { 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
}
}