diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 223a9ca..ce094c0 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -13,10 +13,12 @@ 0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; }; 0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; }; 0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; }; + 12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */; }; 13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; }; 14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; }; 1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; }; 1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; }; + 200E933E672F8B011DC16769 /* VocabImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */; }; 20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; }; 218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; }; 261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; }; @@ -35,7 +37,9 @@ 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; }; 39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; }; 3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; }; + 3F7C308425743919FC4407A8 /* VocabPracticeEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */; }; 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; }; + 419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */; }; 46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; }; 48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; }; 4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; }; @@ -155,6 +159,7 @@ 102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = ""; }; 10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = ""; }; 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = ""; }; + 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabMultipleChoicePracticeView.swift; sourceTree = ""; }; 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = ""; }; 16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; }; 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = ""; }; @@ -174,6 +179,7 @@ 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 = ""; }; + 3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabImageService.swift; sourceTree = ""; }; 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = ""; }; 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = ""; }; 3540936F058728CFD87B1A1E /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = ""; }; @@ -206,6 +212,7 @@ 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = ""; }; 6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = ""; }; 69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = ""; }; + 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = ""; }; 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = ""; }; 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = ""; }; 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = ""; }; @@ -247,6 +254,7 @@ CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; }; D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = ""; }; D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = ""; }; + D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabPracticeEntryView.swift; sourceTree = ""; }; DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = ""; }; DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = ""; }; DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = ""; }; @@ -360,10 +368,22 @@ AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */, 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */, A661ADF1141176EE96774138 /* BookSpeechController.swift */, + 3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */, ); path = Services; sourceTree = ""; }; + 1ECAF79E2138DF73BB1F6403 /* Vocab */ = { + isa = PBXGroup; + children = ( + D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */, + 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */, + 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */, + ); + name = Vocab; + path = Vocab; + sourceTree = ""; + }; 29F9EEAED5A6969FEDEAE227 /* Onboarding */ = { isa = PBXGroup; children = ( @@ -455,6 +475,7 @@ 895E547BEFB5D0FBF676BE33 /* Lyrics */, 43E4D263B0AF47E401A51601 /* Stories */, 74AC8A0D381958D2A14316C3 /* Books */, + 1ECAF79E2138DF73BB1F6403 /* Vocab */, ); path = Practice; sourceTree = ""; @@ -778,6 +799,10 @@ 33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */, 2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */, C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */, + 200E933E672F8B011DC16769 /* VocabImageService.swift in Sources */, + 3F7C308425743919FC4407A8 /* VocabPracticeEntryView.swift in Sources */, + 419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */, + 12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index e860344..0411ff7 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -43,6 +43,7 @@ struct ConjugaApp: App { @State private var verbExampleCache = VerbExampleCache() @State private var reflexiveStore = ReflexiveVerbStore() @State private var youtubeVideoStore = YouTubeVideoStore() + @State private var vocabImageService = VocabImageService() let localContainer: ModelContainer let cloudContainer: ModelContainer @@ -119,6 +120,7 @@ struct ConjugaApp: App { .environment(verbExampleCache) .environment(reflexiveStore) .environment(youtubeVideoStore) + .environment(vocabImageService) .task { let needsSeed = await DataLoader.needsSeeding(container: localContainer) if needsSeed { diff --git a/Conjuga/Conjuga/Services/VocabImageService.swift b/Conjuga/Conjuga/Services/VocabImageService.swift new file mode 100644 index 0000000..4af40dc --- /dev/null +++ b/Conjuga/Conjuga/Services/VocabImageService.swift @@ -0,0 +1,134 @@ +import CryptoKit +import Foundation +import ImagePlayground +import SwiftUI +import UIKit + +/// On-device illustrative images for vocab cards, via Apple Intelligence +/// Image Playground (iOS 18.2+). Generated once per (deck, ES, EN) tuple, +/// cached to disk in the app's Caches directory. +/// +/// On devices/versions without Image Playground the service still works — +/// it just always returns nil and the calling view falls back to a placeholder. +@MainActor +@Observable +final class VocabImageService { + private let cacheRoot: URL = { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let dir = caches.appendingPathComponent("VocabImages", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + /// In-flight generations keyed by cache key so callers asking for the same + /// image while it's being produced share the same task. + private var inFlight: [String: Task] = [:] + + /// Look up a cached image synchronously. Returns nil if not yet generated. + func cachedImage(forKey key: String) -> UIImage? { + let url = cacheFileURL(forKey: key) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return UIImage(contentsOfFile: url.path) + } + + /// Generate (or fetch cached) image for the given English concept. + /// Returns nil on unsupported devices or if generation fails. + func image( + forKey key: String, + concept englishConcept: String + ) async -> UIImage? { + if let cached = cachedImage(forKey: key) { + return cached + } + if let existing = inFlight[key] { + return await existing.value + } + let task = Task { [cacheRoot] in + await Self.generate(concept: englishConcept, savingTo: Self.fileURL(in: cacheRoot, forKey: key)) + } + inFlight[key] = task + let result = await task.value + inFlight[key] = nil + return result + } + + /// Stable cache key for a vocab card. Hash so it survives weird characters + /// in the front/back text. + static func cacheKey(deckId: String, spanish: String, english: String) -> String { + let raw = "\(deckId)|\(spanish)|\(english)" + let digest = SHA256.hash(data: Data(raw.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + /// Cached result of a one-time probe — true if `ImageCreator()` succeeds + /// on this device. Apple Intelligence only lights up on compatible + /// hardware + when the user has it enabled in Settings; `ImageCreator()` + /// throws otherwise. + nonisolated(unsafe) private static var probedAvailability: Bool? = nil + nonisolated(unsafe) private static let probeLock = NSLock() + + /// Whether on-device image generation is available. Cached after first call. + static var isAvailable: Bool { + probeLock.lock() + defer { probeLock.unlock() } + if let cached = probedAvailability { return cached } + // Synchronous probe via Task isn't possible here; default to true on + // iOS 18.2+ and let the first actual generation succeed-or-fail + // produce the real signal. Older OS = never available. + guard #available(iOS 18.2, *) else { + probedAvailability = false + return false + } + return true + } + + /// Mark the service as unavailable based on a real failure (called from + /// `generate(...)` when ImageCreator() init throws). + private static func markUnavailable() { + probeLock.lock() + probedAvailability = false + probeLock.unlock() + } + + // MARK: - Internal + + private func cacheFileURL(forKey key: String) -> URL { + Self.fileURL(in: cacheRoot, forKey: key) + } + + private static func fileURL(in root: URL, forKey key: String) -> URL { + root.appendingPathComponent("\(key).png") + } + + private static func generate(concept: String, savingTo destination: URL) async -> UIImage? { + guard #available(iOS 18.2, *) else { return nil } + + let creator: ImageCreator + do { + creator = try await ImageCreator() + } catch { + print("[VocabImageService] ImageCreator unavailable: \(error)") + markUnavailable() + return nil + } + + let sequence = creator.images( + for: [.text(concept)], + style: .illustration, + limit: 1 + ) + do { + for try await result in sequence { + let cgImage = result.cgImage + let uiImage = UIImage(cgImage: cgImage) + if let png = uiImage.pngData() { + try? png.write(to: destination, options: .atomic) + } + return uiImage + } + } catch { + print("[VocabImageService] generation failed: \(error)") + } + return nil + } +} diff --git a/Conjuga/Conjuga/Views/Practice/PracticeView.swift b/Conjuga/Conjuga/Views/Practice/PracticeView.swift index 212e2f5..a369258 100644 --- a/Conjuga/Conjuga/Views/Practice/PracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/PracticeView.swift @@ -74,12 +74,10 @@ struct PracticeView: View { .padding(.top, 8) } - // Mode selection - VStack(spacing: 12) { - Text("Choose a Mode") - .font(.headline) - .frame(maxWidth: .infinity, alignment: .leading) + // === Section: Conjugation === + sectionHeader("Conjugation") + VStack(spacing: 12) { ForEach(PracticeMode.allCases) { mode in ModeButton(mode: mode) { viewModel.practiceMode = mode @@ -98,6 +96,15 @@ struct PracticeView: View { } .padding(.horizontal) + conjugationFocusButtons + + // === Section: Vocabulary === + sectionHeader("Vocabulary") + vocabSection + + // === Section: Reading === + sectionHeader("Reading") + // Lyrics NavigationLink { LyricsLibraryView() @@ -284,168 +291,6 @@ struct PracticeView: View { .glassEffect(in: RoundedRectangle(cornerRadius: 14)) .padding(.horizontal) - // Quick Actions - VStack(spacing: 12) { - Text("Quick Actions") - .font(.headline) - .frame(maxWidth: .infinity, alignment: .leading) - - // Vocab review - NavigationLink { - VocabReviewView() - } label: { - HStack(spacing: 14) { - Image(systemName: "rectangle.stack.fill") - .font(.title3) - .foregroundStyle(.teal) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text("Vocab Review") - .font(.subheadline.weight(.semibold)) - Text("Review due vocabulary cards") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - let dueCount = VocabReviewView.dueCount(context: cloudModelContext) - if dueCount > 0 { - Text("\(dueCount)") - .font(.caption.weight(.bold)) - .foregroundStyle(.white) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(.teal, in: Capsule()) - } - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .tint(.primary) - .glassEffect(in: RoundedRectangle(cornerRadius: 14)) - - // Common tenses focus - Button { - viewModel.practiceMode = .flashcard - viewModel.focusMode = .commonTenses - viewModel.sessionCorrect = 0 - viewModel.sessionTotal = 0 - viewModel.loadNextCard( - localContext: modelContext, - cloudContext: cloudModelContext - ) - withAnimation { isPracticing = true } - } label: { - HStack(spacing: 14) { - Image(systemName: "star.fill") - .font(.title3) - .foregroundStyle(.orange) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text("Common Tenses") - .font(.subheadline.weight(.semibold)) - Text("Practice the 6 most essential tenses") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .tint(.primary) - .glassEffect(in: RoundedRectangle(cornerRadius: 14)) - - // Weak verbs focus - Button { - viewModel.practiceMode = .flashcard - viewModel.focusMode = .weakVerbs - viewModel.sessionCorrect = 0 - viewModel.sessionTotal = 0 - viewModel.loadNextCard( - localContext: modelContext, - cloudContext: cloudModelContext - ) - withAnimation { isPracticing = true } - } label: { - HStack(spacing: 14) { - Image(systemName: "exclamationmark.triangle") - .font(.title3) - .foregroundStyle(.red) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text("Weak Verbs") - .font(.subheadline.weight(.semibold)) - Text("Focus on verbs you struggle with") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .tint(.primary) - .glassEffect(in: RoundedRectangle(cornerRadius: 14)) - - // Irregularity drills - Menu { - Button("Spelling Changes (c→qu, z→c, ...)") { - startIrregularityDrill(.spelling) - } - Button("Stem Changes (o→ue, e→ie, ...)") { - startIrregularityDrill(.stemChange) - } - Button("Unique Irregulars (ser, ir, ...)") { - startIrregularityDrill(.uniqueIrregular) - } - } label: { - HStack(spacing: 14) { - Image(systemName: "wand.and.stars") - .font(.title3) - .foregroundStyle(.purple) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text("Irregularity Drills") - .font(.subheadline.weight(.semibold)) - Text("Practice by irregularity type") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .tint(.primary) - .glassEffect(in: RoundedRectangle(cornerRadius: 14)) - } - .padding(.horizontal) - // Session stats summary if viewModel.sessionTotal > 0 && !isPracticing { VStack(spacing: 8) { @@ -473,6 +318,150 @@ struct PracticeView: View { } } + // MARK: - Section header + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + } + + // MARK: - Conjugation focus buttons (Common Tenses / Weak Verbs / Irregularity) + + private var conjugationFocusButtons: some View { + VStack(spacing: 12) { + // Common Tenses + Button { + viewModel.practiceMode = .flashcard + viewModel.focusMode = .commonTenses + viewModel.sessionCorrect = 0 + viewModel.sessionTotal = 0 + viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext) + withAnimation { isPracticing = true } + } label: { + practiceRowLabel(icon: "star.fill", color: .orange, + title: "Common Tenses", + subtitle: "Practice the 6 most essential tenses") + } + .tint(.primary) + .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + + // Weak Verbs + Button { + viewModel.practiceMode = .flashcard + viewModel.focusMode = .weakVerbs + viewModel.sessionCorrect = 0 + viewModel.sessionTotal = 0 + viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext) + withAnimation { isPracticing = true } + } label: { + practiceRowLabel(icon: "exclamationmark.triangle", color: .red, + title: "Weak Verbs", + subtitle: "Focus on verbs you struggle with") + } + .tint(.primary) + .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + + // Irregularity drills + Menu { + Button("Spelling Changes (c→qu, z→c, ...)") { startIrregularityDrill(.spelling) } + Button("Stem Changes (o→ue, e→ie, ...)") { startIrregularityDrill(.stemChange) } + Button("Unique Irregulars (ser, ir, ...)") { startIrregularityDrill(.uniqueIrregular) } + } label: { + practiceRowLabel(icon: "wand.and.stars", color: .purple, + title: "Irregularity Drills", + subtitle: "Practice by irregularity type") + } + .tint(.primary) + .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + } + .padding(.horizontal) + } + + // MARK: - Vocabulary section + + private var vocabSection: some View { + VStack(spacing: 12) { + // NEW: Vocab Practice entry (flashcard + MC) + NavigationLink { + VocabPracticeEntryView() + } label: { + practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple, + title: "Vocab Practice", + subtitle: "Flashcards or multiple choice, pick a deck") + } + .tint(.primary) + .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + + // Existing: Vocab Review (due cards) + NavigationLink { + VocabReviewView() + } label: { + HStack(spacing: 14) { + Image(systemName: "rectangle.stack.fill") + .font(.title3) + .foregroundStyle(.teal) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text("Vocab Review") + .font(.subheadline.weight(.semibold)) + Text("Review due vocabulary cards") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + let dueCount = VocabReviewView.dueCount(context: cloudModelContext) + if dueCount > 0 { + Text("\(dueCount)") + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.teal, in: Capsule()) + } + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .tint(.primary) + .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + } + .padding(.horizontal) + } + + private func practiceRowLabel(icon: String, color: Color, title: String, subtitle: String) -> some View { + HStack(spacing: 14) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(color) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + // MARK: - Practice Session View @ViewBuilder diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift new file mode 100644 index 0000000..dc3b9e4 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift @@ -0,0 +1,293 @@ +import SwiftUI +import SharedModels +import SwiftData + +/// English-first flashcard. Front shows the English meaning; tap to reveal +/// the Spanish word with example sentences, an AI-generated illustrative +/// image, and SRS rating buttons. +struct VocabFlashcardPracticeView: View { + /// nil = all decks + let deckId: String? + + @Environment(\.modelContext) private var localContext + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + @Environment(VocabImageService.self) private var imageService + @Environment(\.dismiss) private var dismiss + + @State private var cards: [VocabCard] = [] + @State private var deckLookup: [String: CourseDeck] = [:] + @State private var index: Int = 0 + @State private var revealed: Bool = false + + private var cloudContext: ModelContext { cloudModelContextProvider() } + + private var currentCard: VocabCard? { + guard index < cards.count else { return nil } + return cards[index] + } + + private var sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])? { + guard let card = currentCard else { return nil } + let isReversed = deckLookup[card.deckId]?.isReversed ?? false + let english = isReversed ? card.front : card.back + let spanish = isReversed ? card.back : card.front + return (english, spanish, card.examplesES, card.examplesEN) + } + + var body: some View { + ScrollView { + VStack(spacing: 24) { + progressBar + if let sides { + cardBody(sides) + } else { + completionView + } + } + .padding() + .adaptiveContainer(maxWidth: 720) + } + .navigationTitle("Vocab Flashcards") + .navigationBarTitleDisplayMode(.inline) + .onAppear(perform: loadIfNeeded) + .animation(.smooth, value: revealed) + .animation(.smooth, value: index) + } + + // MARK: - Progress + + private var progressBar: some View { + VStack(spacing: 6) { + ProgressView(value: cards.isEmpty ? 0 : Double(index) / Double(cards.count)) + .tint(.purple) + Text(cards.isEmpty ? "No cards in this deck" : "\(min(index + 1, cards.count)) of \(cards.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // MARK: - Card + + @ViewBuilder + private func cardBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View { + // Front (always visible) + Text(sides.english) + .font(.largeTitle.weight(.bold)) + .multilineTextAlignment(.center) + .padding(.top, 12) + + if revealed { + revealedContent(sides) + } else { + tapToReveal + } + } + + private var tapToReveal: some View { + VStack(spacing: 8) { + Image(systemName: "hand.tap") + .font(.title) + .foregroundStyle(.secondary) + Text("Tap to reveal") + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .frame(minHeight: 200) + .glassEffect(in: RoundedRectangle(cornerRadius: 20)) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.smooth) { revealed = true } + } + } + + private func revealedContent(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View { + VStack(spacing: 18) { + Text(sides.spanish) + .font(.title.weight(.semibold)) + .multilineTextAlignment(.center) + + if let card = currentCard { + VocabIllustration(card: card, deckLookup: deckLookup) + } + + if !sides.examplesES.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(zip(sides.examplesES, sides.examplesEN).enumerated()), id: \.offset) { _, pair in + VStack(alignment: .leading, spacing: 2) { + Text(pair.0).font(.subheadline).italic() + Text(pair.1).font(.caption).foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) + } + + ratingButtons + } + } + + private var ratingButtons: some View { + VStack(spacing: 10) { + Text("How well did you know it?") + .font(.subheadline) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + ratingButton("Again", color: .red, quality: .again) + ratingButton("Hard", color: .orange, quality: .hard) + ratingButton("Good", color: .green, quality: .good) + ratingButton("Easy", color: .blue, quality: .easy) + } + } + } + + private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View { + Button { + rateAndAdvance(quality) + } label: { + Text(label) + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .tint(color) + .glassEffect(in: RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Completion + + private var completionView: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56)) + .foregroundStyle(.green) + Text("Session Complete") + .font(.title2.bold()) + Text("\(cards.count) cards reviewed") + .font(.subheadline) + .foregroundStyle(.secondary) + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + .tint(.purple) + .padding(.top, 12) + } + .padding(.top, 60) + } + + // MARK: - Logic + + private func loadIfNeeded() { + guard cards.isEmpty else { return } + let pool = fetchPool() + cards = pool.shuffled() + deckLookup = Dictionary(uniqueKeysWithValues: fetchDecks().map { ($0.id, $0) }) + } + + private func fetchPool() -> [VocabCard] { + var descriptor = FetchDescriptor() + if let deckId { + descriptor.predicate = #Predicate { $0.deckId == deckId } + } + return (try? localContext.fetch(descriptor)) ?? [] + } + + private func fetchDecks() -> [CourseDeck] { + (try? localContext.fetch(FetchDescriptor())) ?? [] + } + + private func rateAndAdvance(_ quality: ReviewQuality) { + guard let card = currentCard else { return } + CourseReviewStore(context: cloudContext).rate(card: card, quality: quality) + withAnimation(.smooth) { + revealed = false + index += 1 + } + } +} + +// MARK: - Illustration (shared) + +/// AI-generated image for the card's English concept. Generates on first +/// reveal and caches to disk. Falls back to a styled SF Symbol when Image +/// Playground is unavailable. +struct VocabIllustration: View { + let card: VocabCard + let deckLookup: [String: CourseDeck] + + @Environment(VocabImageService.self) private var service + @State private var image: UIImage? + @State private var isGenerating: Bool = false + + private var englishConcept: String { + let isReversed = deckLookup[card.deckId]?.isReversed ?? false + return isReversed ? card.front : card.back + } + + private var spanish: String { + let isReversed = deckLookup[card.deckId]?.isReversed ?? false + return isReversed ? card.back : card.front + } + + private var cacheKey: String { + VocabImageService.cacheKey(deckId: card.deckId, spanish: spanish, english: englishConcept) + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(.purple.opacity(0.08)) + .frame(height: 200) + + if let image { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(maxHeight: 200) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } else if isGenerating { + VStack(spacing: 6) { + ProgressView() + Text("Generating illustration…") + .font(.caption2) + .foregroundStyle(.secondary) + } + } else if !VocabImageService.isAvailable { + placeholder + } else { + ProgressView() + } + } + .frame(maxWidth: .infinity) + .task(id: cacheKey, loadImage) + } + + private var placeholder: some View { + VStack(spacing: 6) { + Image(systemName: "photo") + .font(.title2) + .foregroundStyle(.purple.opacity(0.55)) + Text("Image generation unavailable on this device") + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + } + + @Sendable + private func loadImage() async { + if let cached = service.cachedImage(forKey: cacheKey) { + image = cached + return + } + guard VocabImageService.isAvailable else { return } + isGenerating = true + let result = await service.image(forKey: cacheKey, concept: englishConcept) + isGenerating = false + if let result { + image = result + } + } +} diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift new file mode 100644 index 0000000..35c06c9 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift @@ -0,0 +1,262 @@ +import SwiftUI +import SharedModels +import SwiftData + +/// English-first multiple choice. Prompt shows the English meaning; the user +/// picks the correct Spanish word from 4 options (3 distractors drawn from the +/// same deck, preferring matching part-of-speech via DictionaryService). +/// After answer: reveal correct/incorrect, show examples + image, rate SRS. +struct VocabMultipleChoicePracticeView: View { + let deckId: String? + + @Environment(\.modelContext) private var localContext + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + @Environment(DictionaryService.self) private var dictionary + @Environment(\.dismiss) private var dismiss + + @State private var cards: [VocabCard] = [] + @State private var distractorPool: [VocabCard] = [] + @State private var deckLookup: [String: CourseDeck] = [:] + @State private var index: Int = 0 + @State private var options: [VocabCard] = [] + @State private var selectedOption: VocabCard? = nil + + private var cloudContext: ModelContext { cloudModelContextProvider() } + + private var currentCard: VocabCard? { + guard index < cards.count else { return nil } + return cards[index] + } + + private var sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])? { + guard let card = currentCard else { return nil } + let isReversed = deckLookup[card.deckId]?.isReversed ?? false + let english = isReversed ? card.front : card.back + let spanish = isReversed ? card.back : card.front + return (english, spanish, card.examplesES, card.examplesEN) + } + + var body: some View { + ScrollView { + VStack(spacing: 22) { + progressBar + if let sides { + questionBody(sides) + } else { + completionView + } + } + .padding() + .adaptiveContainer(maxWidth: 720) + } + .navigationTitle("Vocab Multiple Choice") + .navigationBarTitleDisplayMode(.inline) + .onAppear(perform: loadIfNeeded) + .animation(.smooth, value: selectedOption?.id) + .animation(.smooth, value: index) + } + + // MARK: - Progress + + private var progressBar: some View { + VStack(spacing: 6) { + ProgressView(value: cards.isEmpty ? 0 : Double(index) / Double(cards.count)) + .tint(.purple) + Text(cards.isEmpty ? "No cards in this deck" : "\(min(index + 1, cards.count)) of \(cards.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // MARK: - Question + + @ViewBuilder + private func questionBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View { + Text(sides.english) + .font(.largeTitle.weight(.bold)) + .multilineTextAlignment(.center) + .padding(.top, 12) + + if selectedOption == nil { + optionGrid + } else { + revealedContent(sides) + } + } + + private var optionGrid: some View { + VStack(spacing: 10) { + ForEach(options, id: \.id) { option in + Button { + selectedOption = option + } label: { + Text(spanishSide(of: option)) + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + .tint(.primary) + } + } + } + + private func revealedContent(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View { + VStack(spacing: 16) { + answerFeedback(sides) + + if let card = currentCard { + VocabIllustration(card: card, deckLookup: deckLookup) + } + + if !sides.examplesES.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(zip(sides.examplesES, sides.examplesEN).enumerated()), id: \.offset) { _, pair in + VStack(alignment: .leading, spacing: 2) { + Text(pair.0).font(.subheadline).italic() + Text(pair.1).font(.caption).foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) + } + + ratingButtons + } + } + + private func answerFeedback(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View { + let correct = (selectedOption?.id == currentCard?.id) + return VStack(spacing: 6) { + Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(correct ? .green : .red) + Text(correct ? "Correct!" : "Not quite") + .font(.headline) + .foregroundStyle(correct ? .green : .red) + Text(sides.spanish) + .font(.title2.weight(.semibold)) + .padding(.top, 4) + } + } + + private var ratingButtons: some View { + VStack(spacing: 10) { + Text("How well did you know it?") + .font(.subheadline) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + ratingButton("Again", color: .red, quality: .again) + ratingButton("Hard", color: .orange, quality: .hard) + ratingButton("Good", color: .green, quality: .good) + ratingButton("Easy", color: .blue, quality: .easy) + } + } + } + + private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View { + Button { + rateAndAdvance(quality) + } label: { + Text(label) + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .tint(color) + .glassEffect(in: RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Completion + + private var completionView: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56)) + .foregroundStyle(.green) + Text("Session Complete") + .font(.title2.bold()) + Text("\(cards.count) cards reviewed") + .font(.subheadline) + .foregroundStyle(.secondary) + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + .tint(.purple) + .padding(.top, 12) + } + .padding(.top, 60) + } + + // MARK: - Logic + + private func loadIfNeeded() { + guard cards.isEmpty else { return } + let pool = fetchPool() + cards = pool.shuffled() + distractorPool = pool + deckLookup = Dictionary(uniqueKeysWithValues: fetchDecks().map { ($0.id, $0) }) + if cards.count < 4 { + distractorPool = fetchAllCards() + } + prepareOptions() + } + + private func fetchPool() -> [VocabCard] { + var descriptor = FetchDescriptor() + if let deckId { + descriptor.predicate = #Predicate { $0.deckId == deckId } + } + return (try? localContext.fetch(descriptor)) ?? [] + } + + private func fetchAllCards() -> [VocabCard] { + (try? localContext.fetch(FetchDescriptor())) ?? [] + } + + private func fetchDecks() -> [CourseDeck] { + (try? localContext.fetch(FetchDescriptor())) ?? [] + } + + private func prepareOptions() { + guard let card = currentCard else { options = []; return } + let correctPOS = partOfSpeech(for: card) + let candidates = distractorPool.filter { $0.id != card.id } + + let posMatches = correctPOS.flatMap { pos in + candidates.filter { partOfSpeech(for: $0) == pos } + } ?? [] + let pickedDistractors: [VocabCard] + if posMatches.count >= 3 { + pickedDistractors = Array(posMatches.shuffled().prefix(3)) + } else { + // Fill with random others + var pool = posMatches + let remaining = candidates.filter { c in !pool.contains(where: { $0.id == c.id }) } + pool.append(contentsOf: remaining.shuffled()) + pickedDistractors = Array(pool.prefix(3)) + } + options = ([card] + pickedDistractors).shuffled() + } + + private func partOfSpeech(for card: VocabCard) -> String? { + let spanish = spanishSide(of: card).lowercased() + .trimmingCharacters(in: .punctuationCharacters) + .trimmingCharacters(in: .whitespaces) + return dictionary.lookup(spanish)?.partOfSpeech + } + + private func spanishSide(of card: VocabCard) -> String { + let isReversed = deckLookup[card.deckId]?.isReversed ?? false + return isReversed ? card.back : card.front + } + + private func rateAndAdvance(_ quality: ReviewQuality) { + guard let card = currentCard else { return } + CourseReviewStore(context: cloudContext).rate(card: card, quality: quality) + index += 1 + selectedOption = nil + prepareOptions() + } +} diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabPracticeEntryView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabPracticeEntryView.swift new file mode 100644 index 0000000..2305557 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabPracticeEntryView.swift @@ -0,0 +1,141 @@ +import SwiftUI +import SharedModels +import SwiftData + +/// Entry screen for vocabulary practice. User picks a deck (or "All") and a +/// mode (Flashcard / Multiple Choice), then taps Start. The session view +/// fetches the pool, shuffles, and runs the cards. +struct VocabPracticeEntryView: View { + enum Mode: String, CaseIterable, Identifiable { + case flashcard + case multipleChoice + + var id: String { rawValue } + var label: String { + switch self { + case .flashcard: return "Flashcard" + case .multipleChoice: return "Multiple Choice" + } + } + var systemImage: String { + switch self { + case .flashcard: return "rectangle.on.rectangle" + case .multipleChoice: return "checklist" + } + } + } + + @Query(sort: [SortDescriptor(\CourseDeck.courseName), SortDescriptor(\CourseDeck.weekNumber)]) + private var decks: [CourseDeck] + + @AppStorage("vocabPracticeLastDeckId") private var lastDeckId: String = "" + @AppStorage("vocabPracticeLastMode") private var lastModeRaw: String = Mode.flashcard.rawValue + + @State private var selectedDeckId: String? = nil + @State private var mode: Mode = .flashcard + @State private var startedSession: SessionConfig? = nil + + /// Sentinel for "All decks" in the picker. + private let allDecksTag = "__all__" + + private struct SessionConfig: Hashable { + let deckId: String? // nil = all decks + let mode: Mode + } + + var body: some View { + Form { + Section("Mode") { + Picker("Mode", selection: $mode) { + ForEach(Mode.allCases) { m in + Label(m.label, systemImage: m.systemImage).tag(m) + } + } + .pickerStyle(.segmented) + } + + Section("Deck") { + if decks.isEmpty { + Text("No course decks available yet.") + .foregroundStyle(.secondary) + } else { + Picker("Deck", selection: Binding( + get: { selectedDeckId ?? allDecksTag }, + set: { selectedDeckId = ($0 == allDecksTag ? nil : $0) } + )) { + Text("All decks").tag(allDecksTag) + ForEach(grouped, id: \.course) { group in + Section(group.course) { + ForEach(group.decks) { deck in + Text(deckLabel(deck)).tag(deck.id) + } + } + } + } + .pickerStyle(.navigationLink) + } + } + + Section { + Button { + persistChoice() + startedSession = SessionConfig(deckId: selectedDeckId, mode: mode) + } label: { + Label("Start", systemImage: "play.fill") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + .tint(.purple) + .disabled(decks.isEmpty) + } + } + .navigationTitle("Vocab Practice") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $startedSession) { config in + switch config.mode { + case .flashcard: + VocabFlashcardPracticeView(deckId: config.deckId) + case .multipleChoice: + VocabMultipleChoicePracticeView(deckId: config.deckId) + } + } + .onAppear(perform: restoreChoice) + } + + // MARK: - Helpers + + private struct DeckGroup { + let course: String + let decks: [CourseDeck] + } + + private var grouped: [DeckGroup] { + let byCourse = Dictionary(grouping: decks, by: \.courseName) + return byCourse.keys.sorted().map { name in + DeckGroup(course: name, decks: (byCourse[name] ?? []).sorted { + if $0.weekNumber != $1.weekNumber { return $0.weekNumber < $1.weekNumber } + return $0.title < $1.title + }) + } + } + + private func deckLabel(_ deck: CourseDeck) -> String { + "W\(deck.weekNumber) — \(deck.title)" + } + + private func restoreChoice() { + if !lastDeckId.isEmpty, decks.contains(where: { $0.id == lastDeckId }) { + selectedDeckId = lastDeckId + } + if let m = Mode(rawValue: lastModeRaw) { + mode = m + } + } + + private func persistChoice() { + lastDeckId = selectedDeckId ?? "" + lastModeRaw = mode.rawValue + } +}