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 */; };
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,6 +65,7 @@ struct VocabFlashcardView: View {
|
|||||||
.font(.title.weight(.medium))
|
.font(.title.weight(.medium))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
Button {
|
Button {
|
||||||
speechService.speak(card.front)
|
speechService.speak(card.front)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -70,6 +75,20 @@ struct VocabFlashcardView: View {
|
|||||||
}
|
}
|
||||||
.glassEffect(in: .circle)
|
.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 {
|
if isStemChangingDeck {
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.smooth) { showConjugation.toggle() }
|
withAnimation(.smooth) { showConjugation.toggle() }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user