diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 2d2bf10..4525c5f 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -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 = ""; }; 1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = ""; }; 20D1904DF07E0A6816134CF3 /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = ""; }; + 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.swift; sourceTree = ""; }; 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = ""; }; 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = ""; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = ""; }; @@ -212,6 +215,7 @@ 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = ""; }; 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActionsView.swift; sourceTree = ""; }; 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = ""; }; + 8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyView.swift; sourceTree = ""; }; 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = ""; }; 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = ""; }; 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = ""; }; @@ -339,6 +343,7 @@ 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */, D570252DA3DCDD9217C71863 /* WidgetDataService.swift */, AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */, + 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */, ); path = Services; sourceTree = ""; @@ -515,6 +520,7 @@ 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */, 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */, 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */, + 8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */, ); path = Course; sourceTree = ""; @@ -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; }; diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index 3df81f8..3c52fdf 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -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 ) diff --git a/Conjuga/Conjuga/Services/ExtraStudyStore.swift b/Conjuga/Conjuga/Services/ExtraStudyStore.swift new file mode 100644 index 0000000..ba1c613 --- /dev/null +++ b/Conjuga/Conjuga/Services/ExtraStudyStore.swift @@ -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( + predicate: #Predicate { $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( + predicate: #Predicate { + $0.courseName == courseName && $0.weekNumber == weekNumber + } + ) + return (try? context.fetchCount(descriptor)) ?? 0 + } + + func countsByWeek(courseName: String) -> [Int: Int] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $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( + predicate: #Predicate { + $0.courseName == courseName && $0.weekNumber == weekNumber + }, + sortBy: [SortDescriptor(\.markedAt)] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + func fetchIds(courseName: String, weekNumber: Int) -> Set { + 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 +} diff --git a/Conjuga/Conjuga/Views/Course/CourseView.swift b/Conjuga/Conjuga/Views/Course/CourseView.swift index 64f08a9..d9d3d27 100644 --- a/Conjuga/Conjuga/Views/Course/CourseView.swift +++ b/Conjuga/Conjuga/Views/Course/CourseView.swift @@ -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())) ?? [] } + + 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 { diff --git a/Conjuga/Conjuga/Views/Course/DeckStudyView.swift b/Conjuga/Conjuga/Views/Course/DeckStudyView.swift index 4f688fe..44f1b12 100644 --- a/Conjuga/Conjuga/Views/Course/DeckStudyView.swift +++ b/Conjuga/Conjuga/Views/Course/DeckStudyView.swift @@ -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) { diff --git a/Conjuga/Conjuga/Views/Course/ExtraStudyView.swift b/Conjuga/Conjuga/Views/Course/ExtraStudyView.swift new file mode 100644 index 0000000..12175b9 --- /dev/null +++ b/Conjuga/Conjuga/Views/Course/ExtraStudyView.swift @@ -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( + predicate: #Predicate { $0.deckId == deckId } + ) + let deckCards = (try? localContext.fetch(descriptor)) ?? [] + collected.append(contentsOf: deckCards.filter { + markIds.contains(CourseCardStore.reviewKey(for: $0)) + }) + } + cards = collected + loaded = true + } +} diff --git a/Conjuga/Conjuga/Views/Course/VocabFlashcardView.swift b/Conjuga/Conjuga/Views/Course/VocabFlashcardView.swift index d86e74c..372dcec 100644 --- a/Conjuga/Conjuga/Views/Course/VocabFlashcardView.swift +++ b/Conjuga/Conjuga/Views/Course/VocabFlashcardView.swift @@ -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 = [] private var isStemChangingDeck: Bool { (deckTitle ?? "").localizedCaseInsensitiveContains("stem changing") @@ -61,14 +65,29 @@ struct VocabFlashcardView: View { .font(.title.weight(.medium)) .multilineTextAlignment(.center) - Button { - speechService.speak(card.front) - } label: { - Image(systemName: "speaker.wave.2.fill") - .font(.title3) - .padding(12) + HStack(spacing: 12) { + Button { + speechService.speak(card.front) + } label: { + Image(systemName: "speaker.wave.2.fill") + .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 { Button { @@ -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 { diff --git a/Conjuga/SharedModels/Sources/SharedModels/ExtraStudyMark.swift b/Conjuga/SharedModels/Sources/SharedModels/ExtraStudyMark.swift new file mode 100644 index 0000000..897462a --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/ExtraStudyMark.swift @@ -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 + } +}