8 Commits

Author SHA1 Message Date
Trey T 4b8c966685 Merge book-reader read-aloud features into vocab-session-tuning 2026-06-09 10:44:22 -05:00
Trey T 0ad448a600 Vocab sessions — new-word throttle + per-type status metrics
- New "New Words Per Session" setting (verbs/nouns/adjectives, 0–20 or
  All, default 10). Session builders now fill with due reviews first, then
  add fresh words only up to that throttle in the leftover room — so reviews
  take priority and new vocab is introduced steadily. Fixes both flashcards
  and multiple choice; Review Learned untouched.
- New per-type word-status metrics in Settings (New / Overdue / Due today /
  Upcoming / Learned + Total), scoped to the enabled levels, shown under the
  Verb Levels and Vocabulary Levels sections. Backed by WordStatusMetrics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:43:42 -05:00
Trey T 32395bac5d Book reader — speed as a 5-option dropdown with multiplier labels
Replace the 3-way segmented speed control with a dropdown menu offering
0.5× / 0.75× / 1× / 1.25× / 1.5×, with evened-out underlying AVSpeech
rates anchored at 1× = 0.50. Align the default saved rate to 0.50 so 1×
is selected on a fresh install (was 0.45, which matched no option).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:20:58 -05:00
Trey T b97da5e85e Book reader — choose read-aloud start + play/pause/resume
- Long-press a word to mark where read-aloud begins (session-only). A
  distinct indigo marker shows the spot; long-pressing it again clears it.
  Play honors the marker on a fresh start.
- BookSpeechController can start mid-paragraph by speaking a substring;
  a per-entry wordIndexOffset keeps word highlighting aligned to the full
  paragraph's coordinates.
- The main button is now Play / Pause / Resume — it resumes in place
  instead of restarting, so pausing, flipping to English and back, then
  resuming continues from the same word. A separate Stop button ends the
  session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:08:07 -05:00
Trey T aab64116b3 Vocab study — per-type session sizes + Review Learned multiple choice
- Settings: split the single session-size picker into separate Verbs /
  Nouns / Adjectives pickers. Nouns and adjectives previously shared one
  hidden limit; they now use nounSessionCardLimit / adjectiveSessionCardLimit.
- LexemePool.sessionCardLimit is now per part-of-speech.
- Multiple-choice views (verb/noun/adjective) gained a kind param so
  Review Learned can run as multiple choice, not just flashcards. The
  cram pass drives the in-session queue only and leaves the long-term
  SRS schedule untouched.
- PracticeView: each section now offers Review Learned — Flashcards and
  Review Learned — Multiple Choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:54:10 -05:00
Trey T 179400b90d Course — Review Course Material row with bundled weekly PDFs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:40:02 -05:00
Trey T 696eafa64f Noun & adjective practice — Multiple Choice, Review Learned, Review
Mirror the four-entry Vocabulary section for nouns and adjectives, so each
POS gets the same set of practice modes the verb flow already had:

- Noun/Adjective Flashcards (existing) — English → Spanish reveal with
  article for nouns. Now accepts `kind:` to share the view with the
  Review-Learned cram pass.
- Noun/Adjective Multiple Choice — English prompt, 4 Spanish options
  drawn from the current session pool (1 correct + 3 random distractors).
  Same SRS rating writes as Flashcards.
- Review Learned — `NounFlashcardPracticeView(kind: .reviewLearned)` and
  the adjective equivalent. Cycles through already-studied lexemes with
  no schedule changes; mirrors `VocabFlashcardPracticeView`'s
  reviewLearned kind.
- Noun/Adjective Review — fetches due `LexemeReviewCard` rows by POS,
  Spanish-front / English-reveal flashcards rated directly against the
  SRS schedule. Each exposes a static `dueCount(context:)` used by the
  practice-row badge.

Wiring:
- New `LexemeSessionKind` enum (standard / reviewLearned) in
  LexemeSessionQueue.swift, mirroring `VocabSessionKind`.
- Noun + Adjective Flashcard views branch load/persist/answer on `kind`
  so Review Learned doesn't touch the persisted study group or reschedule
  cross-session SRS.
- Practice screen gets dedicated "Nouns" and "Adjectives" sections
  (between Vocabulary and Reading), each with 4 NavigationLinks shaped
  exactly like the Vocabulary section. The previous single-link Noun and
  Adjective entries in the Reading section are removed.
- PracticeView caches `nounDueCount` / `adjectiveDueCount` in @State and
  refreshes on appear + after sessions end, so the badge doesn't trigger
  LexemeReviewCard fetchCount on every body re-evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:59:42 -05:00
Trey T 7da98d786c Vocab study — noun & adjective flashcards with CEFR level toggles
Add SRS-driven noun and adjective flashcards modeled on the existing verb
flashcard flow:

- SharedModels/Lexeme — catalog of non-verb vocab, frequency-ranked, with
  gender for nouns and optional example sentences. Seeded from a bundled
  vocab_lexemes.json built by Scripts/vocab/build_lexemes.py, which joins
  frequency.csv + es-en.data from a pinned doozan/spanish_data commit
  (CC-BY-SA: hermitdave/FrequencyWords + Wiktionary). 1,449 nouns and 600
  adjectives, each with Wiktionary-sourced gender and (where available)
  an example sentence with English translation.
- LexemeReviewCard + LexemeReviewStore — cloud-synced SM-2 SRS, keyed by
  partOfSpeech + lexemeId + drillMode so future drill modes can coexist.
- LexemeSessionQueue + LexemePool — parallel to VocabSessionQueue; fresh
  cards sort by frequency rank.
- LexemeStudyGroup — cloud-synced resumable session per
  (partOfSpeech, drillMode).
- NounFlashcardPracticeView + AdjectiveFlashcardPracticeView — same flow
  as VocabFlashcardPracticeView: English prompt → tap to reveal Spanish
  → Again/Hard/Good/Easy. Nouns reveal with their article (la taza, el
  problema) so gender is taught alongside meaning, not as a separate
  quiz. Example sentence shown when present.

CEFR-style level toggles:
- LexemeLevel enum (A1/A2/B1/B2/C1+) derived from frequencyRank with
  standard Spanish-frequency-dictionary cutoffs (250/500/1000/2000).
- UserProgress.selectedLexemeLevels — cloud-synced multi-select, defaults
  to A1+A2 on first launch.
- SettingsView gains a "Vocabulary Levels" section with five toggles; the
  existing "Levels" section is renamed "Verb Levels" for clarity.
- Due SRS cards always surface regardless of toggles. Disabling a level
  only stops new cards from that band entering the pool.

PracticeView gets "Nouns" and "Adjectives" rows under "Books".

DataLoader: new lexemeDataVersion gate that re-seeds the Lexeme table
from vocab_lexemes.json independent of book seeding. project.yml lists
the new JSON resource and the existing book_olly-vol2.json (which the
previous build was silently excluding because xcodegen rewrote the
project from project.yml).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:16:55 -05:00
45 changed files with 3507 additions and 117 deletions
+3
View File
@@ -45,6 +45,9 @@ scrape/
*.pdf *.pdf
*.epub *.epub
epub_extract/ epub_extract/
# Exception: weekly course-material PDFs are bundled into the app and must
# travel with the repo so fresh clones build with the feature working.
!Conjuga/Conjuga/CourseMaterials/*.pdf
# Textbook extraction artifacts — regenerate locally via run_pipeline.sh. # Textbook extraction artifacts — regenerate locally via run_pipeline.sh.
# Scripts are committed; their generated outputs are not. # Scripts are committed; their generated outputs are not.
+186 -64
View File
@@ -10,75 +10,90 @@
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; }; 00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; }; 04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; };
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; }; 05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; };
067996B3D0D768ADBED1E52F /* AdjectiveFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */; };
0A29763DF0ED8F24730BCE5A /* vocab_lexemes.json in Resources */ = {isa = PBXBuildFile; fileRef = 7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */; };
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; }; 0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; }; 0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; }; 0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */; }; 0E49DCF141F81D1C3A94C459 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */; };
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */; };
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; }; 13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; }; 14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; }; 1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; }; 1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
2088B73D83D4D6A6CABEFBCE /* AdjectiveReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */; };
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; }; 20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; };
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; }; 218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
25A02F8B96285407EFD30817 /* LexemeStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */; };
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; }; 261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; }; 27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; };
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */; }; 28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */; };
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; }; 2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
2B76DE203AEEBAE4536BD4F0 /* NounMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */; };
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; }; 2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */; };
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A661ADF1141176EE96774138 /* BookSpeechController.swift */; };
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; }; 33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
345AB6723C15590031B75A01 /* Beginner_I_W2.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 9518665E390E8BE4D7D957FE /* Beginner_I_W2.pdf */; };
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; }; 352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
3535A6B73D03486EB2E43823 /* Beginner_I_W1.pdf in Resources */ = {isa = PBXBuildFile; fileRef = CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */; };
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; }; 354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; }; 35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; }; 362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; }; 36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; }; 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
381D007E8E35E5D91CD549A3 /* ExtraStudyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */; };
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; }; 39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; }; 3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; };
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; }; 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */; }; 44C6084491E3056A4C3A890B /* AdjectiveMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */; };
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; }; 46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; }; 48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; };
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; }; 4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */; }; 4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */; };
4E00225D668FDFA3026B7627 /* BookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3475931F1AD16054741E65 /* BookChapterListView.swift */; };
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; }; 50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; }; 519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; }; 51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
5224FD701320B7DBCEFDD95B /* Beginner_I_W4.pdf in Resources */ = {isa = PBXBuildFile; fileRef = B07767E9BC347964B98C0F3D /* Beginner_I_W4.pdf */; };
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; }; 53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
55FBABFE026058CFE8B40CB6 /* LexemeReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */; };
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; }; 5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ACC4C35491174257770941 /* VerbReviewStore.swift */; }; 5BB8A0C1A8A6374B04F50781 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */; };
5C1C0011594A2C06BCD777A4 /* Beginner_I_W7.pdf in Resources */ = {isa = PBXBuildFile; fileRef = E8CEEA98D1805C5BA20A4559 /* Beginner_I_W7.pdf */; };
5CBAD967B3545EA7560761C6 /* Beginner_I_W3.pdf in Resources */ = {isa = PBXBuildFile; fileRef = EBDEA606F038A4A3DF1967E3 /* Beginner_I_W3.pdf */; };
5EA89448E97D76CFC97DC4E2 /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */; };
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; }; 5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; }; 5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; };
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; }; 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */; }; 61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */; };
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; }; 615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD4AF96186662567525F8C4 /* BookReaderView.swift */; }; 6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */; };
65382875879BD537F5358381 /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */; };
6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */; };
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; }; 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
6C0C71E0D56C199C375609AC /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA4750E84A7FA51532407CF /* BookLibraryView.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 */; };
7321D046BB60D40B1D27121E /* NounReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4365AC54DB1DA5D7017CB42 /* NounReviewView.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 */; };
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; }; 7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; }; 81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; };
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; }; 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; }; 82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */; };
848BADEF0FC04EDB4644E923 /* BookVoicePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */; };
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; }; 84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
877B0306A15A0AD680B361F8 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = 03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */; };
8B393A61B32D7D3488618ADE /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20423155763A77A050727EC /* BookReaderView.swift */; };
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */; }; 8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */; };
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; }; 8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; };
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; }; 8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; }; 8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; };
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */; }; 90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */; };
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; }; 943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
962B9B4FF15EADF2A30C5743 /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */; };
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */ = {isa = PBXBuildFile; fileRef = 3644B5ED77F29A65877D926A /* reflexive_verbs.json */; }; 97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */ = {isa = PBXBuildFile; fileRef = 3644B5ED77F29A65877D926A /* reflexive_verbs.json */; };
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; }; 97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; }; 983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; };
995C466AE3C95A95DB9457A1 /* Beginner_I_W6.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 8B54119366CF052443A8C080 /* Beginner_I_W6.pdf */; };
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */; };
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; }; 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; }; 9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; }; A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
@@ -89,7 +104,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 */; }; AEFD25146A64DF9A07FE32B4 /* LexemeReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.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 */; };
@@ -98,29 +113,35 @@
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; }; BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; }; BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; }; C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
C0DF369A6E30F01514A78CA1 /* Beginner_I_W5.pdf in Resources */ = {isa = PBXBuildFile; fileRef = EBBA697AFA8988BBB15C4717 /* Beginner_I_W5.pdf */; };
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; }; C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; }; C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
C384F12E910EE909AA97ECD9 /* LexemeSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */; };
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; }; C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */; }; C7952958B55BAD218CB3ABC5 /* BookSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */; };
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; }; C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; }; CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */; }; CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */; };
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; }; CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
D3328F0C9CF6A2CB154A85B3 /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */; };
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; }; D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; }; D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; }; D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; }; D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; }; D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; }; DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
DCA3805DCC1D03BEEDED73A8 /* ExtraStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */; };
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; }; DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */; }; E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C168499F60BC7AFE5100C572 /* BookChapterListView.swift */; };
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; }; E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; }; E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; }; E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
EB7CF33BA416BD7B5D995FF4 /* Beginner_I_W8.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 83AA457D61B9D2C86C301236 /* Beginner_I_W8.pdf */; };
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; }; ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; }; F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 539736EB2AB8D149ED0F9C39 /* textbook_data.json */; }; F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 539736EB2AB8D149ED0F9C39 /* textbook_data.json */; };
F22FD38D5CD6A89CC5940B0E /* CourseMaterialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E42513164C858C8CBE1A70AF /* CourseMaterialView.swift */; };
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */ = {isa = PBXBuildFile; fileRef = 6658C35E454C137B53FC05A4 /* youtube_videos.json */; }; F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */ = {isa = PBXBuildFile; fileRef = 6658C35E454C137B53FC05A4 /* youtube_videos.json */; };
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; }; F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; }; F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
@@ -155,12 +176,14 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleGenerator.swift; sourceTree = "<group>"; }; 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleGenerator.swift; sourceTree = "<group>"; };
0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; }; 0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; };
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "book_olly-vol2.json"; sourceTree = "<group>"; };
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; }; 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveReviewView.swift; sourceTree = "<group>"; };
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; }; 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; };
0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; }; 102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; }; 10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; }; 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; }; 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
@@ -171,16 +194,14 @@
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; }; 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; }; 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; }; 1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookVoicePickerSheet.swift; sourceTree = "<group>"; };
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; }; 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
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>"; }; 256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveMultipleChoicePracticeView.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>"; };
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; };
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; }; 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
3540936F058728CFD87B1A1E /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; }; 3540936F058728CFD87B1A1E /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; };
3644B5ED77F29A65877D926A /* reflexive_verbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reflexive_verbs.json; sourceTree = "<group>"; }; 3644B5ED77F29A65877D926A /* reflexive_verbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reflexive_verbs.json; sourceTree = "<group>"; };
@@ -200,7 +221,9 @@
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; }; 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; }; 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; }; 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePickerSheet.swift; sourceTree = "<group>"; };
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; sourceTree = "<group>"; }; 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; sourceTree = "<group>"; };
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveFlashcardPracticeView.swift; sourceTree = "<group>"; };
539736EB2AB8D149ED0F9C39 /* textbook_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_data.json; sourceTree = "<group>"; }; 539736EB2AB8D149ED0F9C39 /* textbook_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_data.json; sourceTree = "<group>"; };
58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; }; 58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; }; 5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
@@ -212,7 +235,6 @@
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; }; 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = "<group>"; }; 6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = "<group>"; };
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; }; 69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; }; 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; }; 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; }; 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
@@ -223,58 +245,78 @@
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; }; 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; }; 777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; }; 79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = vocab_lexemes.json; sourceTree = "<group>"; };
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; }; 80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; }; 833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; }; 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
83AA457D61B9D2C86C301236 /* Beginner_I_W8.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W8.pdf; sourceTree = "<group>"; };
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.swift; sourceTree = "<group>"; };
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; }; 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
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>"; };
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
8B54119366CF052443A8C080 /* Beginner_I_W6.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W6.pdf; 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>"; };
9518665E390E8BE4D7D957FE /* Beginner_I_W2.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W2.pdf; sourceTree = "<group>"; };
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; }; 9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A2ACC4C35491174257770941 /* VerbReviewStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; }; A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.swift; sourceTree = "<group>"; };
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; }; A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounFlashcardPracticeView.swift; sourceTree = "<group>"; };
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; }; A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; }; A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
A661ADF1141176EE96774138 /* BookSpeechController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = youtube_videos.md; sourceTree = "<group>"; }; A6EC7C278E4287D91A0DB355 /* youtube_videos.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = youtube_videos.md; sourceTree = "<group>"; };
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; }; A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeStudyGroup.swift; sourceTree = "<group>"; };
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = "<group>"; }; AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = "<group>"; };
B07767E9BC347964B98C0F3D /* Beginner_I_W4.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W4.pdf; sourceTree = "<group>"; };
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; }; B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; };
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; }; BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeReviewCard.swift; sourceTree = "<group>"; };
C168499F60BC7AFE5100C572 /* BookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
C20423155763A77A050727EC /* BookReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookReaderView.swift; sourceTree = "<group>"; };
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; }; C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
CAA4750E84A7FA51532407CF /* BookLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; };
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; }; CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordStatusMetrics.swift; sourceTree = "<group>"; };
CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W1.pdf; sourceTree = "<group>"; };
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; }; 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 = "<group>"; }; D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeReviewStore.swift; sourceTree = "<group>"; };
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; }; D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; }; DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; }; DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; }; DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; }; DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabStudyGroup.swift; sourceTree = "<group>"; };
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; }; E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; }; E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
E42513164C858C8CBE1A70AF /* CourseMaterialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseMaterialView.swift; sourceTree = "<group>"; };
E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounReviewView.swift; sourceTree = "<group>"; };
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; }; E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
E8CEEA98D1805C5BA20A4559 /* Beginner_I_W7.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W7.pdf; sourceTree = "<group>"; };
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; }; E8D95887B18216FCA71643D6 /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; }; E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */ = {isa = PBXFileReference; includeInIndex = 1; path = "book_olly-vol2.json"; sourceTree = "<group>"; }; EBBA697AFA8988BBB15C4717 /* Beginner_I_W5.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W5.pdf; sourceTree = "<group>"; };
EBDEA606F038A4A3DF1967E3 /* Beginner_I_W3.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W3.pdf; sourceTree = "<group>"; };
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.swift; sourceTree = "<group>"; }; EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.swift; sourceTree = "<group>"; };
EDD4AF96186662567525F8C4 /* BookReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookReaderView.swift; sourceTree = "<group>"; }; EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraStudyView.swift; sourceTree = "<group>"; };
EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeSessionQueue.swift; sourceTree = "<group>"; };
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; }; F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabStudyGroup.swift; sourceTree = "<group>"; };
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; }; F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = "<group>"; }; FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = "<group>"; };
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; }; FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.swift; sourceTree = "<group>"; };
FF3475931F1AD16054741E65 /* BookChapterListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -302,6 +344,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */, A014EEC3EE08E945FBBA5335 /* Assets.xcassets */,
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */,
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */, 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */,
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */, 9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */, 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
@@ -310,13 +353,15 @@
3644B5ED77F29A65877D926A /* reflexive_verbs.json */, 3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
539736EB2AB8D149ED0F9C39 /* textbook_data.json */, 539736EB2AB8D149ED0F9C39 /* textbook_data.json */,
3540936F058728CFD87B1A1E /* textbook_vocab.json */, 3540936F058728CFD87B1A1E /* textbook_vocab.json */,
7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */,
6658C35E454C137B53FC05A4 /* youtube_videos.json */, 6658C35E454C137B53FC05A4 /* youtube_videos.json */,
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */, A6EC7C278E4287D91A0DB355 /* youtube_videos.md */,
2610354CB0D62BD8A19BEC20 /* CourseMaterials */,
353C5DE41FD410FA82E3AED7 /* Models */, 353C5DE41FD410FA82E3AED7 /* Models */,
23B49FBE9B44D8734D96625F /* Scripts */,
1994867BC8E985795A172854 /* Services */, 1994867BC8E985795A172854 /* Services */,
3C75490F53C34A37084FF478 /* ViewModels */, 3C75490F53C34A37084FF478 /* ViewModels */,
A81CA75762B08D35D5B7A44D /* Views */, A81CA75762B08D35D5B7A44D /* Views */,
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */,
); );
path = Conjuga; path = Conjuga;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -345,11 +390,15 @@
children = ( children = (
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */, 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */, B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */,
3A96C065B8787DEC6818E497 /* ConversationService.swift */, 3A96C065B8787DEC6818E497 /* ConversationService.swift */,
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */, DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */, DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */, 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */,
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */, 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */,
EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */,
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */, 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */, 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */, 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
@@ -365,25 +414,37 @@
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */, 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */, EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */, 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */,
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */, 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */, D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */,
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */, AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
1ECAF79E2138DF73BB1F6403 /* Vocab */ = { 23B49FBE9B44D8734D96625F /* Scripts */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */, 6D8FBC65B3D300DB2966E989 /* guide-enrichment */,
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
); );
name = Vocab; path = Scripts;
path = Vocab; sourceTree = "<group>";
};
2610354CB0D62BD8A19BEC20 /* CourseMaterials */ = {
isa = PBXGroup;
children = (
CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */,
9518665E390E8BE4D7D957FE /* Beginner_I_W2.pdf */,
EBDEA606F038A4A3DF1967E3 /* Beginner_I_W3.pdf */,
B07767E9BC347964B98C0F3D /* Beginner_I_W4.pdf */,
EBBA697AFA8988BBB15C4717 /* Beginner_I_W5.pdf */,
8B54119366CF052443A8C080 /* Beginner_I_W6.pdf */,
E8CEEA98D1805C5BA20A4559 /* Beginner_I_W7.pdf */,
83AA457D61B9D2C86C301236 /* Beginner_I_W8.pdf */,
);
path = CourseMaterials;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = { 29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
@@ -400,14 +461,16 @@
0313D24F96E6A0039C34341F /* DailyLog.swift */, 0313D24F96E6A0039C34341F /* DailyLog.swift */,
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */, F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */, 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */,
C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */,
AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */,
626873572466403C0288090D /* QuizType.swift */, 626873572466403C0288090D /* QuizType.swift */,
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */, 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
69D98E1564C6538056D81200 /* TenseEndingTable.swift */, 69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
3BC3247457109FC6BF00D85B /* TenseInfo.swift */, 3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
DAFE27F29412021AEC57E728 /* TestResult.swift */, DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */, E536AD1180FE10576EAC884A /* UserProgress.swift */,
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */, F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */,
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -462,6 +525,7 @@
5A23E5D4EFE8E46030CA9D77 /* Practice */ = { 5A23E5D4EFE8E46030CA9D77 /* Practice */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */,
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */, 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
D232CDA43CC9218D748BA121 /* ClozeView.swift */, D232CDA43CC9218D748BA121 /* ClozeView.swift */,
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */, 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
@@ -469,30 +533,48 @@
1F842EB5E566C74658D918BB /* HandwritingView.swift */, 1F842EB5E566C74658D918BB /* HandwritingView.swift */,
20D1904DF07E0A6816134CF3 /* ListeningView.swift */, 20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */, DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */,
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */, 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */, 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */, 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
10C16AA6022E4742898745CE /* TypingView.swift */, 10C16AA6022E4742898745CE /* TypingView.swift */,
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */, E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
9CD612E55440D22B877EA8FE /* Books */,
8FB89F19B33894DDF27C8EC2 /* Chat */, 8FB89F19B33894DDF27C8EC2 /* Chat */,
895E547BEFB5D0FBF676BE33 /* Lyrics */, 895E547BEFB5D0FBF676BE33 /* Lyrics */,
43E4D263B0AF47E401A51601 /* Stories */, 43E4D263B0AF47E401A51601 /* Stories */,
74AC8A0D381958D2A14316C3 /* Books */, 730BD7F59F4C97D87EF98FB1 /* Vocab */,
1ECAF79E2138DF73BB1F6403 /* Vocab */,
); );
path = Practice; path = Practice;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
74AC8A0D381958D2A14316C3 /* Books */ = { 6D8FBC65B3D300DB2966E989 /* guide-enrichment */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */, 7DE0F6354CF73BDA0CE728BA /* in */,
FF3475931F1AD16054741E65 /* BookChapterListView.swift */, C36A0F3B1A4B759412ADB4E5 /* out */,
EDD4AF96186662567525F8C4 /* BookReaderView.swift */,
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */,
); );
name = Books; path = "guide-enrichment";
path = Books; sourceTree = "<group>";
};
730BD7F59F4C97D87EF98FB1 /* Vocab */ = {
isa = PBXGroup;
children = (
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */,
256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */,
A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */,
0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */,
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */,
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */,
);
path = Vocab;
sourceTree = "<group>";
};
7DE0F6354CF73BDA0CE728BA /* in */ = {
isa = PBXGroup;
children = (
);
path = in;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
8102F7FA5BFE6D38B2212AD3 /* Guide */ = { 8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
@@ -526,6 +608,17 @@
path = Chat; path = Chat;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
9CD612E55440D22B877EA8FE /* Books */ = {
isa = PBXGroup;
children = (
C168499F60BC7AFE5100C572 /* BookChapterListView.swift */,
CAA4750E84A7FA51532407CF /* BookLibraryView.swift */,
C20423155763A77A050727EC /* BookReaderView.swift */,
50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */,
);
path = Books;
sourceTree = "<group>";
};
A591A3B6F1F13D23D68D7A9D = { A591A3B6F1F13D23D68D7A9D = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -565,20 +658,28 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */, 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */,
E42513164C858C8CBE1A70AF /* CourseMaterialView.swift */,
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */, 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
833516C5D57F164C8660A479 /* CourseView.swift */, 833516C5D57F164C8660A479 /* CourseView.swift */,
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */, 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */,
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */, F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */, 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
39908548430FDF01D76201FB /* TextbookChapterView.swift */, 39908548430FDF01D76201FB /* TextbookChapterView.swift */,
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>";
}; };
C36A0F3B1A4B759412ADB4E5 /* out */ = {
isa = PBXGroup;
children = (
);
path = out;
sourceTree = "<group>";
};
F605D24E5EA11065FD18AF7E /* Products */ = { F605D24E5EA11065FD18AF7E /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -690,14 +791,23 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */, F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
3535A6B73D03486EB2E43823 /* Beginner_I_W1.pdf in Resources */,
345AB6723C15590031B75A01 /* Beginner_I_W2.pdf in Resources */,
5CBAD967B3545EA7560761C6 /* Beginner_I_W3.pdf in Resources */,
5224FD701320B7DBCEFDD95B /* Beginner_I_W4.pdf in Resources */,
C0DF369A6E30F01514A78CA1 /* Beginner_I_W5.pdf in Resources */,
995C466AE3C95A95DB9457A1 /* Beginner_I_W6.pdf in Resources */,
5C1C0011594A2C06BCD777A4 /* Beginner_I_W7.pdf in Resources */,
EB7CF33BA416BD7B5D995FF4 /* Beginner_I_W8.pdf in Resources */,
877B0306A15A0AD680B361F8 /* book_olly-vol2.json in Resources */,
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */, CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */, 2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */, 97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */, F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */,
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */, A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */,
0A29763DF0ED8F24730BCE5A /* vocab_lexemes.json in Resources */,
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */, F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */, 983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */,
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -710,14 +820,23 @@
files = ( files = (
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */, 97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */, 261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
067996B3D0D768ADBED1E52F /* AdjectiveFlashcardPracticeView.swift in Sources */,
44C6084491E3056A4C3A890B /* AdjectiveMultipleChoicePracticeView.swift in Sources */,
2088B73D83D4D6A6CABEFBCE /* AdjectiveReviewView.swift in Sources */,
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */, 48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */, CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */,
6C0C71E0D56C199C375609AC /* BookLibraryView.swift in Sources */,
8B393A61B32D7D3488618ADE /* BookReaderView.swift in Sources */,
C7952958B55BAD218CB3ABC5 /* BookSpeechController.swift in Sources */,
848BADEF0FC04EDB4644E923 /* BookVoicePickerSheet.swift in Sources */,
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */, B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */,
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */, 8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */,
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */, 9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */,
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */, 04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */,
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */, C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */, ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */,
F22FD38D5CD6A89CC5940B0E /* CourseMaterialView.swift in Sources */,
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */, C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */, 8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */, F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
@@ -728,6 +847,8 @@
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */, 84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */, 90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */, B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
381D007E8E35E5D91CD549A3 /* ExtraStudyStore.swift in Sources */,
DCA3805DCC1D03BEEDED73A8 /* ExtraStudyView.swift in Sources */,
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */, 14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */, D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */, A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
@@ -735,11 +856,16 @@
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */, 8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */, 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */, F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
D3328F0C9CF6A2CB154A85B3 /* GuideCrossLinks.swift in Sources */,
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */, 760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */, 3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */,
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */, E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */, 33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */, 28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
AEFD25146A64DF9A07FE32B4 /* LexemeReviewCard.swift in Sources */,
55FBABFE026058CFE8B40CB6 /* LexemeReviewStore.swift in Sources */,
C384F12E910EE909AA97ECD9 /* LexemeSessionQueue.swift in Sources */,
25A02F8B96285407EFD30817 /* LexemeStudyGroup.swift in Sources */,
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */, A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */,
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */, 519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */, B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
@@ -748,6 +874,9 @@
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */, 7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */, C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */, 82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */,
2B76DE203AEEBAE4536BD4F0 /* NounMultipleChoicePracticeView.swift in Sources */,
7321D046BB60D40B1D27121E /* NounReviewView.swift in Sources */,
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */, 13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */, 2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */, 352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
@@ -787,26 +916,19 @@
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */, BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */, 4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */, 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
0E49DCF141F81D1C3A94C459 /* VerbReviewStore.swift in Sources */,
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */, AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */, FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
5EA89448E97D76CFC97DC4E2 /* VocabFlashcardPracticeView.swift in Sources */,
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */, 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
5BB8A0C1A8A6374B04F50781 /* VocabMultipleChoicePracticeView.swift in Sources */,
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */, 78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
962B9B4FF15EADF2A30C5743 /* VocabSessionQueue.swift in Sources */,
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */,
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */, 05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */,
6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */,
65382875879BD537F5358381 /* BookLibraryView.swift in Sources */,
4E00225D668FDFA3026B7627 /* BookChapterListView.swift in Sources */,
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */,
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */,
6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+3 -1
View File
@@ -73,6 +73,7 @@ struct ConjugaApp: App {
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self, ReviewCard.self, CourseReviewCard.self, VerbReviewCard.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, ExtraStudyMark.self, VocabStudyGroup.self, TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
LexemeReviewCard.self, LexemeStudyGroup.self,
]), ]),
cloudKitDatabase: .private("iCloud.com.conjuga.app") cloudKitDatabase: .private("iCloud.com.conjuga.app")
) )
@@ -80,6 +81,7 @@ struct ConjugaApp: App {
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self, for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.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, ExtraStudyMark.self, VocabStudyGroup.self, TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
LexemeReviewCard.self, LexemeStudyGroup.self,
configurations: cloudConfig configurations: cloudConfig
) )
@@ -253,7 +255,7 @@ struct ConjugaApp: App {
/// Clears accumulated stale schema metadata from previous container configurations. /// Clears accumulated stale schema metadata from previous container configurations.
/// Bump the version number to force another reset if the schema changes again. /// Bump the version number to force another reset if the schema changes again.
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) { private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
let resetVersion = 5 // bump: Book/BookChapter added to local container let resetVersion = 6 // bump: Lexeme added to local container
let key = "localStoreResetVersion" let key = "localStoreResetVersion"
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,38 @@
import SwiftData
import Foundation
/// SRS record for non-verb vocab cards (nouns, adjectives, ). Keyed by
/// `(partOfSpeech, lexemeId, drillMode)` so a noun's gender drill and its
/// English-recall drill progress independently. Lives in the cloud container
/// alongside `VerbReviewCard` so vocab progress syncs across devices.
///
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
/// `LexemeReviewStore` since CloudKit forbids `@Attribute(.unique)`.
@Model
final class LexemeReviewCard {
var id: String = ""
var lexemeId: String = ""
var partOfSpeech: String = ""
var drillMode: String = ""
var easeFactor: Double = 2.5
var interval: Int = 0
var repetitions: Int = 0
var dueDate: Date = Date()
var lastReviewDate: Date?
init(lexemeId: String, partOfSpeech: String, drillMode: String) {
self.id = Self.makeId(
lexemeId: lexemeId,
partOfSpeech: partOfSpeech,
drillMode: drillMode
)
self.lexemeId = lexemeId
self.partOfSpeech = partOfSpeech
self.drillMode = drillMode
}
static func makeId(lexemeId: String, partOfSpeech: String, drillMode: String) -> String {
"\(partOfSpeech)|\(lexemeId)|\(drillMode)"
}
}
@@ -0,0 +1,101 @@
import Foundation
import SwiftData
/// Per-(POS, drillMode) active study group, mirroring `VocabStudyGroup`.
/// Keying by drill mode means a noun gender drill and an adjective agreement
/// drill can each have their own resumable session at the same time.
///
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create.
@Model
final class LexemeStudyGroup {
var id: String = ""
var partOfSpeech: String = ""
var drillMode: String = ""
/// JSON-encoded `[StoredLexemeEntry]` the in-session queue in order.
var entriesJSON: Data = Data()
var learnedCount: Int = 0
var createdAt: Date = Date()
init(
partOfSpeech: String,
drillMode: String,
entriesJSON: Data,
learnedCount: Int
) {
self.id = Self.activeID(partOfSpeech: partOfSpeech, drillMode: drillMode)
self.partOfSpeech = partOfSpeech
self.drillMode = drillMode
self.entriesJSON = entriesJSON
self.learnedCount = learnedCount
self.createdAt = Date()
}
static func activeID(partOfSpeech: String, drillMode: String) -> String {
"active-\(partOfSpeech)-\(drillMode)"
}
var entries: [StoredLexemeEntry] {
(try? JSONDecoder().decode([StoredLexemeEntry].self, from: entriesJSON)) ?? []
}
}
/// One lexeme's spot in the persisted study group.
struct StoredLexemeEntry: Codable {
var lexemeId: String
/// Raw value of `LexemeSessionQueue.CardState`.
var state: String
}
/// Fetch / persist / clear the active group for one `(POS, drillMode)` pair.
struct LexemeStudyGroupStore {
let context: ModelContext
let partOfSpeech: String
let drillMode: String
private var activeID: String {
LexemeStudyGroup.activeID(partOfSpeech: partOfSpeech, drillMode: drillMode)
}
func activeGroup() -> LexemeStudyGroup? {
let id = activeID
let descriptor = FetchDescriptor<LexemeStudyGroup>(
predicate: #Predicate<LexemeStudyGroup> { $0.id == id },
sortBy: [SortDescriptor(\LexemeStudyGroup.createdAt, order: .reverse)]
)
return (try? context.fetch(descriptor))?.first
}
func persist(entries: [StoredLexemeEntry], learnedCount: Int) {
let data = (try? JSONEncoder().encode(entries)) ?? Data()
let id = activeID
let descriptor = FetchDescriptor<LexemeStudyGroup>(
predicate: #Predicate<LexemeStudyGroup> { $0.id == id },
sortBy: [SortDescriptor(\LexemeStudyGroup.createdAt, order: .reverse)]
)
let existing = (try? context.fetch(descriptor)) ?? []
if let newest = existing.first {
newest.entriesJSON = data
newest.learnedCount = learnedCount
for duplicate in existing.dropFirst() { context.delete(duplicate) }
} else {
context.insert(LexemeStudyGroup(
partOfSpeech: partOfSpeech,
drillMode: drillMode,
entriesJSON: data,
learnedCount: learnedCount
))
}
try? context.save()
}
func clear() {
let id = activeID
let descriptor = FetchDescriptor<LexemeStudyGroup>(
predicate: #Predicate<LexemeStudyGroup> { $0.id == id }
)
for group in (try? context.fetch(descriptor)) ?? [] {
context.delete(group)
}
try? context.save()
}
}
+33
View File
@@ -26,6 +26,10 @@ final class UserProgress {
var selectedLevelsBlob: String = "" var selectedLevelsBlob: String = ""
var enabledIrregularCategoriesBlob: String = "" var enabledIrregularCategoriesBlob: String = ""
// Multi-select CEFR levels for the noun/adjective vocab catalog
// separate from the verb levels above so the two are independent.
var selectedLexemeLevelsBlob: String = ""
init() {} init() {}
var selectedVerbLevel: VerbLevel { var selectedVerbLevel: VerbLevel {
@@ -107,6 +111,35 @@ final class UserProgress {
selectedVerbLevels = values selectedVerbLevels = values
} }
/// CEFR-style levels currently enabled for noun + adjective flashcards.
/// First-ever read (blob empty) defaults to A1+A2 a beginner-friendly
/// starting point. Once the user touches any toggle, the blob is no
/// longer empty and exactly reflects their selection (including the
/// "all off" state, which shows the empty-state message).
var selectedLexemeLevels: Set<LexemeLevel> {
get {
if selectedLexemeLevelsBlob.isEmpty {
return [.a1, .a2]
}
let raw = decodeStringArray(from: selectedLexemeLevelsBlob, fallback: [])
return Set(raw.compactMap(LexemeLevel.init(rawValue:)))
}
set {
let sorted = newValue.map(\.rawValue)
selectedLexemeLevelsBlob = Self.encodeStringArray(sorted)
}
}
func setLexemeLevelEnabled(_ level: LexemeLevel, enabled: Bool) {
var values = selectedLexemeLevels
if enabled {
values.insert(level)
} else {
values.remove(level)
}
selectedLexemeLevels = values
}
func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) { func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) {
var values = enabledIrregularCategories var values = enabledIrregularCategories
if enabled { if enabled {
@@ -43,6 +43,12 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
let paragraphIndex: Int let paragraphIndex: Int
let text: String let text: String
let wordRanges: [Range<String.Index>] let wordRanges: [Range<String.Index>]
/// Words skipped at the front of this paragraph when the caller asked to
/// start mid-paragraph. The utterance is built from a substring, so the
/// synth's word indices are local to that substring; add this offset to
/// report a word index in the full paragraph's coordinate space (which
/// is what the view highlights against). 0 for whole-paragraph entries.
let wordIndexOffset: Int
} }
override init() { override init() {
@@ -55,17 +61,40 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
/// `currentParagraphIndex` are positions in the original `paragraphs` /// `currentParagraphIndex` are positions in the original `paragraphs`
/// array vocab lines are skipped internally but the visible index space /// array vocab lines are skipped internally but the visible index space
/// matches what the caller passed. /// matches what the caller passed.
func start(paragraphs: [String], from startIndex: Int = 0) { func start(paragraphs: [String], fromParagraph startIndex: Int = 0, word startWordIndex: Int? = nil) {
stop() stop()
configureAudioSession() configureAudioSession()
var entries: [QueueEntry] = [] var entries: [QueueEntry] = []
for (idx, p) in paragraphs.enumerated() where idx >= startIndex { for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
if Self.isVocabLine(p) { continue } if Self.isVocabLine(p) { continue }
// Apply the word offset only to the first paragraph actually read,
// and only when it's the paragraph the caller pointed at. A skipped
// vocab line at startIndex, word 0, or an out-of-range index all
// fall through to reading the whole paragraph.
if entries.isEmpty,
idx == startIndex,
let startWord = startWordIndex,
startWord > 0 {
let fullRanges = Self.wordRanges(in: p)
if startWord < fullRanges.count {
let substring = String(p[fullRanges[startWord].lowerBound...])
entries.append(QueueEntry(
paragraphIndex: idx,
text: substring,
wordRanges: Self.wordRanges(in: substring),
wordIndexOffset: startWord
))
continue
}
}
entries.append(QueueEntry( entries.append(QueueEntry(
paragraphIndex: idx, paragraphIndex: idx,
text: p, text: p,
wordRanges: Self.wordRanges(in: p) wordRanges: Self.wordRanges(in: p),
wordIndexOffset: 0
)) ))
} }
guard !entries.isEmpty else { return } guard !entries.isEmpty else { return }
@@ -189,8 +218,11 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
let idx = entry.wordRanges.firstIndex { let idx = entry.wordRanges.firstIndex {
$0.lowerBound <= lower && lower < $0.upperBound $0.lowerBound <= lower && lower < $0.upperBound
} }
if let idx, idx != currentWordIndex { if let idx {
currentWordIndex = idx let reported = entry.wordIndexOffset + idx
if reported != currentWordIndex {
currentWordIndex = reported
}
} }
} }
+100 -2
View File
@@ -9,9 +9,12 @@ actor DataLoader {
static let textbookDataVersion = 14 static let textbookDataVersion = 14
static let textbookDataKey = "textbookDataVersion" static let textbookDataKey = "textbookDataVersion"
static let bookDataVersion = 6 // bump: BookChapter.paragraphCount added static let bookDataVersion = 7 // Lexeme table + WordGloss.gender added
static let bookDataKey = "bookDataVersion" static let bookDataKey = "bookDataVersion"
static let lexemeDataVersion = 1 // initial seeded from vocab_lexemes.json
static let lexemeDataKey = "lexemeDataVersion"
/// Quick check: does the DB need seeding or course data refresh? /// Quick check: does the DB need seeding or course data refresh?
static func needsSeeding(container: ModelContainer) async -> Bool { static func needsSeeding(container: ModelContainer) async -> Bool {
let context = ModelContext(container) let context = ModelContext(container)
@@ -602,7 +605,8 @@ actor DataLoader {
glossary[word] = WordGloss( glossary[word] = WordGloss(
baseForm: fields["baseForm"] ?? word, baseForm: fields["baseForm"] ?? word,
english: fields["english"] ?? "", english: fields["english"] ?? "",
partOfSpeech: fields["partOfSpeech"] ?? "" partOfSpeech: fields["partOfSpeech"] ?? "",
gender: fields["gender"]
) )
} }
} }
@@ -657,6 +661,100 @@ actor DataLoader {
return true return true
} }
// MARK: - Lexeme catalog (Phase 3 of vocab study)
/// Re-seed the `Lexeme` catalog if the version has changed or the rows
/// are missing. The catalog is sourced from the bundled
/// `vocab_lexemes.json` (built by `Scripts/vocab/build_lexemes.py` from
/// doozan/spanish_data) independent from book seeding so a catalog
/// refresh doesn't require touching books.
static func refreshLexemesIfNeeded(container: ModelContainer) async {
let shared = UserDefaults.standard
let context = ModelContext(container)
let existingCount = (try? context.fetchCount(FetchDescriptor<Lexeme>())) ?? 0
let storedVersion = shared.integer(forKey: lexemeDataKey)
let versionCurrent = storedVersion >= lexemeDataVersion
print("[DataLoader] refreshLexemesIfNeeded: existing=\(existingCount) stored=\(storedVersion) target=\(lexemeDataVersion) versionCurrent=\(versionCurrent)")
if versionCurrent && existingCount > 0 { return }
if let existing = try? context.fetch(FetchDescriptor<Lexeme>()) {
for lexeme in existing { context.delete(lexeme) }
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: lexeme wipe save failed: \(error)")
return
}
if seedLexemesFromCatalog(context: context) {
shared.set(lexemeDataVersion, forKey: lexemeDataKey)
print("[DataLoader] Lexeme data re-seeded to version \(lexemeDataVersion)")
} else {
print("[DataLoader] Lexeme reseed produced no rows — leaving version key untouched")
}
}
/// Read `vocab_lexemes.json` from the app bundle and insert one `Lexeme`
/// per entry. Returns true when at least one row persisted.
private static func seedLexemesFromCatalog(context: ModelContext) -> Bool {
guard let url = Bundle.main.url(forResource: "vocab_lexemes", withExtension: "json") else {
print("[DataLoader] no vocab_lexemes.json bundled — skipping lexeme seed")
return false
}
guard let data = try? Data(contentsOf: url),
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
print("[DataLoader] ERROR: vocab_lexemes.json malformed")
return false
}
var inserted = 0
// Defensive: the build script already dedupes, but skip any stray
// dupes so we never throw on the unique-constraint save.
var seen: Set<String> = []
for entry in array {
guard let baseForm = entry["baseForm"] as? String, !baseForm.isEmpty,
let english = entry["english"] as? String, !english.isEmpty,
let pos = entry["partOfSpeech"] as? String, !pos.isEmpty else {
continue
}
let dedupKey = "\(pos):\(baseForm)"
if seen.contains(dedupKey) { continue }
seen.insert(dedupKey)
let lexeme = Lexeme(
id: Lexeme.makeID(sourceBookSlug: "catalog", partOfSpeech: pos, baseForm: baseForm),
partOfSpeech: pos,
baseForm: baseForm,
english: english,
gender: entry["gender"] as? String,
sourceBookSlug: "catalog",
frequencyRank: (entry["frequencyRank"] as? Int) ?? 0,
exampleES: entry["exampleES"] as? String,
exampleEN: entry["exampleEN"] as? String
)
context.insert(lexeme)
inserted += 1
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: lexeme save failed: \(error)")
return false
}
let persisted = (try? context.fetchCount(FetchDescriptor<Lexeme>())) ?? 0
guard persisted > 0 else {
print("[DataLoader] ERROR: seeded \(inserted) lexemes but persisted count is 0")
return false
}
print("Lexeme seeding complete: \(persisted) lexemes from catalog")
return true
}
/// Slugs of books bundled with the app. Kept explicit so device installs /// Slugs of books bundled with the app. Kept explicit so device installs
/// don't depend on `Bundle.urls(forResourcesWithExtension:subdirectory:)` /// don't depend on `Bundle.urls(forResourcesWithExtension:subdirectory:)`
/// successfully enumerating the bundle that API has been observed to /// successfully enumerating the bundle that API has been observed to
@@ -0,0 +1,61 @@
import Foundation
import SharedModels
import SwiftData
/// SRS rating for non-verb vocab cards. Mirrors `VerbReviewStore` but keyed
/// by `(partOfSpeech, lexemeId, drillMode)` so independent drills against the
/// same lexeme don't fight over one schedule.
struct LexemeReviewStore {
let context: ModelContext
@discardableResult
func fetchOrCreateReviewCard(
lexemeId: String,
partOfSpeech: String,
drillMode: String
) -> LexemeReviewCard {
let id = LexemeReviewCard.makeId(
lexemeId: lexemeId,
partOfSpeech: partOfSpeech,
drillMode: drillMode
)
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> { $0.id == id }
)
if let existing = (try? context.fetch(descriptor))?.first {
return existing
}
let card = LexemeReviewCard(
lexemeId: lexemeId,
partOfSpeech: partOfSpeech,
drillMode: drillMode
)
context.insert(card)
return card
}
func rate(
lexemeId: String,
partOfSpeech: String,
drillMode: String,
quality: ReviewQuality
) {
let card = fetchOrCreateReviewCard(
lexemeId: lexemeId,
partOfSpeech: partOfSpeech,
drillMode: drillMode
)
let result = SRSEngine.review(
quality: quality,
currentEase: card.easeFactor,
currentInterval: card.interval,
currentReps: card.repetitions
)
card.easeFactor = result.easeFactor
card.interval = result.interval
card.repetitions = result.repetitions
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
card.lastReviewDate = Date()
try? context.save()
}
}
@@ -0,0 +1,239 @@
import Foundation
import SharedModels
import SwiftData
/// Which pool a `LexemeSessionQueue` draws from. Mirrors `VocabSessionKind`.
enum LexemeSessionKind {
/// Due-first + new lexemes from enabled CEFR levels, capped the
/// standard SRS session. Ratings update the long-term schedule.
case standard
/// Lexemes already studied at least once, most-recent first, uncapped
/// and unfiltered a consolidation cram. Ratings drive the in-session
/// queue only and do NOT reschedule (long-term SM-2 due dates left
/// untouched, parallel to `VocabSessionKind.reviewLearned`).
case reviewLearned
}
/// In-session learning-step queue for `Lexeme`-based vocab practice the
/// non-verb analog of `VocabSessionQueue`. Same Anki-style position-based
/// requeue: Again/Hard requeue close, Good advances state then graduates on
/// the second pass, Easy graduates immediately. `answer` returns a
/// `ReviewQuality` only when the card graduates that's the rating fed to
/// the cross-session `LexemeReviewStore`.
struct LexemeSessionQueue {
enum CardState: String {
case new
case learning
case review
}
enum Rating {
case again, hard, good, easy
}
struct Entry: Identifiable {
let id = UUID()
let lexeme: Lexeme
var state: CardState
}
let drillMode: String
private(set) var queue: [Entry]
private(set) var learnedCount: Int = 0
private let originalLexemes: [Lexeme]
init(lexemes: [Lexeme], drillMode: String) {
self.drillMode = drillMode
self.originalLexemes = lexemes
self.queue = lexemes.map { Entry(lexeme: $0, state: .new) }
}
init(entries: [(lexeme: Lexeme, state: CardState)], drillMode: String, learnedCount: Int) {
self.drillMode = drillMode
self.originalLexemes = entries.map(\.lexeme)
self.queue = entries.map { Entry(lexeme: $0.lexeme, state: $0.state) }
self.learnedCount = learnedCount
}
func snapshot() -> [(lexemeId: String, state: CardState)] {
queue.map { ($0.lexeme.id, $0.state) }
}
var current: Entry? { queue.first }
var isComplete: Bool { queue.isEmpty }
var remainingCount: Int { queue.count }
var progress: Double {
let total = learnedCount + queue.count
return total == 0 ? 1 : Double(learnedCount) / Double(total)
}
@discardableResult
mutating func answer(_ rating: Rating) -> ReviewQuality? {
guard !queue.isEmpty else { return nil }
var entry = queue.removeFirst()
switch rating {
case .again:
entry.state = .learning
insert(entry, offset: Int.random(in: 5...8))
return nil
case .hard:
entry.state = .learning
insert(entry, offset: Int.random(in: 7...10))
return nil
case .good:
if entry.state == .review {
learnedCount += 1
return .good
}
entry.state = .review
insert(entry, offset: Int.random(in: 16...24))
return nil
case .easy:
learnedCount += 1
return .easy
}
}
mutating func restart() {
queue = originalLexemes.shuffled().map { Entry(lexeme: $0, state: .new) }
learnedCount = 0
}
private mutating func insert(_ entry: Entry, offset: Int) {
let idx = min(queue.count, offset)
queue.insert(entry, at: idx)
}
}
// MARK: - Session lexeme pool
/// Builds a session for a given POS + drill mode: due-first per
/// `LexemeReviewCard`, then fresh (never-studied) lexemes, capped.
enum LexemePool {
/// Per-session cap for a part of speech, from its "Cards per session"
/// setting. Nouns read `nounSessionCardLimit`, adjectives
/// `adjectiveSessionCardLimit`; anything else falls back to the legacy
/// shared `lexemeSessionCardLimit`. 0/unset 20. Mirrors
/// `VocabVerbPool.sessionCardLimit`.
static func sessionCardLimit(for partOfSpeech: String) -> Int {
let key: String
switch partOfSpeech {
case "noun": key = "nounSessionCardLimit"
case "adjective": key = "adjectiveSessionCardLimit"
default: key = "lexemeSessionCardLimit"
}
let stored = UserDefaults.standard.integer(forKey: key)
return stored == 0 ? 20 : stored
}
/// Max brand-new words to introduce per session for a POS, from its
/// "New words per session" setting. 0 is a valid value (review-only), so
/// "unset" is distinguished from 0 and defaults to 10. 999 means "no
/// throttle" (fill whatever room reviews leave).
static func newWordsPerSession(for partOfSpeech: String) -> Int {
let key: String
switch partOfSpeech {
case "noun": key = "nounNewWordsPerSession"
case "adjective": key = "adjectiveNewWordsPerSession"
default: key = "lexemeNewWordsPerSession"
}
guard UserDefaults.standard.object(forKey: key) != nil else { return 10 }
return UserDefaults.standard.integer(forKey: key)
}
static func sessionLexemes(
partOfSpeech: String,
drillMode: String,
enabledLevels: Set<LexemeLevel>,
localContext: ModelContext,
cloudContext: ModelContext
) -> [Lexeme] {
let pool = fetchStudyable(partOfSpeech: partOfSpeech, context: localContext)
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
}
)
let reviewCards = (try? cloudContext.fetch(descriptor)) ?? []
let cardById = Dictionary(
reviewCards.map { ($0.lexemeId, $0) },
uniquingKeysWith: { existing, _ in existing }
)
let now = Date()
var due: [(lexeme: Lexeme, dueDate: Date)] = []
var fresh: [Lexeme] = []
for lexeme in pool {
if let card = cardById[lexeme.id] {
if card.dueDate <= now {
// Due cards surface regardless of current level toggles
// SRS isn't level-gated. Already-studied cards keep
// coming back on their schedule.
due.append((lexeme, card.dueDate))
}
} else if enabledLevels.contains(LexemeLevel.level(forRank: lexeme.frequencyRank)) {
// Fresh (never-studied) cards only enter the pool from
// levels the user has on. Disabling a level is the lever
// for "don't introduce me to harder/easier words yet."
fresh.append(lexeme)
}
}
due.sort { $0.dueDate < $1.dueDate }
// Fresh cards surface in frequency order most-useful words first.
// Lexemes without a rank (frequencyRank == 0) sort last.
fresh.sort { lhs, rhs in
let l = lhs.frequencyRank == 0 ? Int.max : lhs.frequencyRank
let r = rhs.frequencyRank == 0 ? Int.max : rhs.frequencyRank
if l != r { return l < r }
return lhs.baseForm < rhs.baseForm
}
// Reviews take priority: due cards fill the session first, then up to
// `newMax` fresh words take whatever room is left (Anki-style new-card
// throttle). 999 = no throttle (old behavior: fill the cap with fresh).
let cap = sessionCardLimit(for: partOfSpeech)
let newMax = newWordsPerSession(for: partOfSpeech)
let dueTaken = Array(due.map(\.lexeme).prefix(cap))
let remaining = cap - dueTaken.count
let newTaken = Array(fresh.prefix(min(newMax, remaining)))
return dueTaken + newTaken
}
/// Lexemes the user has already studied at least once for `(POS, drill)`,
/// most-recently-studied first. Mirrors `VocabVerbPool.reviewLearnedVerbs`.
static func reviewLearnedLexemes(
partOfSpeech: String,
drillMode: String,
localContext: ModelContext,
cloudContext: ModelContext
) -> [Lexeme] {
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
}
)
let reviewCards = (try? cloudContext.fetch(descriptor)) ?? []
let sorted = reviewCards.sorted {
($0.lastReviewDate ?? .distantPast) > ($1.lastReviewDate ?? .distantPast)
}
let pool = fetchStudyable(partOfSpeech: partOfSpeech, context: localContext)
let byId = Dictionary(pool.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
return sorted.compactMap { byId[$0.lexemeId] }
}
/// Lexemes for a POS. The catalog (`vocab_lexemes.json`) only emits
/// nouns that have a known gender, so no extra filter is needed here.
private static func fetchStudyable(partOfSpeech: String, context: ModelContext) -> [Lexeme] {
let descriptor = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { $0.partOfSpeech == partOfSpeech }
)
return (try? context.fetch(descriptor)) ?? []
}
}
@@ -11,6 +11,7 @@ enum StartupCoordinator {
await DataLoader.refreshCourseDataIfNeeded(container: localContainer) await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer) await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
await DataLoader.refreshBooksDataIfNeeded(container: localContainer) await DataLoader.refreshBooksDataIfNeeded(container: localContainer)
await DataLoader.refreshLexemesIfNeeded(container: localContainer)
} }
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup. /// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
@@ -142,6 +142,15 @@ enum VocabVerbPool {
return stored == 0 ? 20 : stored return stored == 0 ? 20 : stored
} }
/// Max brand-new verbs to introduce per session, from the "New verbs per
/// session" setting. 0 is valid (review-only), so "unset" defaults to 10;
/// 999 means "no throttle". Mirrors `LexemePool.newWordsPerSession`.
static var newWordsPerSession: Int {
let key = "vocabNewWordsPerSession"
guard UserDefaults.standard.object(forKey: key) != nil else { return 10 }
return UserDefaults.standard.integer(forKey: key)
}
static func sessionVerbs( static func sessionVerbs(
localContext: ModelContext, localContext: ModelContext,
cloudContext: ModelContext cloudContext: ModelContext
@@ -178,8 +187,14 @@ enum VocabVerbPool {
due.sort { $0.dueDate < $1.dueDate } due.sort { $0.dueDate < $1.dueDate }
fresh.sort { $0.rank < $1.rank } fresh.sort { $0.rank < $1.rank }
let ordered = due.map(\.verb) + fresh // Reviews take priority: due verbs fill the session first, then up to
return Array(ordered.prefix(sessionCardLimit)) // `newMax` fresh verbs take whatever room is left. 999 = no throttle.
let cap = sessionCardLimit
let newMax = newWordsPerSession
let dueTaken = Array(due.map(\.verb).prefix(cap))
let remaining = cap - dueTaken.count
let newTaken = Array(fresh.prefix(min(newMax, remaining)))
return dueTaken + newTaken
} }
/// Verbs the user has already studied at least once (have a /// Verbs the user has already studied at least once (have a
@@ -0,0 +1,107 @@
import Foundation
import SharedModels
import SwiftData
/// Status breakdown for a word type, scoped to the levels the user currently
/// has enabled. Buckets are mutually exclusive and sum to `total` (the count
/// of enabled-level words of that type).
struct StatusCounts: Equatable {
var new = 0 // never studied (no review card)
var overdue = 0 // card due before today
var dueToday = 0 // card due today
var upcoming = 0 // scheduled for the future, still maturing (interval < mature)
var learned = 0 // scheduled for the future and mature (interval >= mature)
var total: Int { new + overdue + dueToday + upcoming + learned }
}
/// Counts words by SRS status for the Settings progress readout. Pure counting
/// over the level-scoped reference pool + review cards no mutation.
enum WordStatusMetrics {
/// Anki convention: a card with an interval of three weeks or more is
/// considered "mature" rather than still being learned.
private static let matureIntervalDays = 21
static func verbCounts(
selectedLevels: Set<VerbLevel>,
localContext: ModelContext,
cloudContext: ModelContext
) -> StatusCounts {
let store = ReferenceStore(context: localContext)
let levelStrings = Set(selectedLevels.map(\.rawValue))
// Mirror `VocabVerbPool.sessionVerbs`: an empty selection means "all".
let verbs = levelStrings.isEmpty ? store.fetchVerbs() : store.fetchVerbs(selectedLevels: levelStrings)
let cards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
let byId = Dictionary(cards.map { ($0.verbId, $0) }, uniquingKeysWith: { existing, _ in existing })
var counts = StatusCounts()
let calendar = Calendar.current
let now = Date()
for verb in verbs {
if let card = byId[verb.id] {
bump(&counts, interval: card.interval, dueDate: card.dueDate, now: now, calendar: calendar)
} else {
counts.new += 1
}
}
return counts
}
static func lexemeCounts(
partOfSpeech: String,
drillMode: String = "recall",
selectedLevels: Set<LexemeLevel>,
localContext: ModelContext,
cloudContext: ModelContext
) -> StatusCounts {
let lexDescriptor = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { $0.partOfSpeech == partOfSpeech }
)
let all = (try? localContext.fetch(lexDescriptor)) ?? []
// Scope to the enabled CEFR levels same gate the fresh pool uses.
let pool = all.filter { selectedLevels.contains(LexemeLevel.level(forRank: $0.frequencyRank)) }
let cardDescriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
}
)
let cards = (try? cloudContext.fetch(cardDescriptor)) ?? []
let byId = Dictionary(cards.map { ($0.lexemeId, $0) }, uniquingKeysWith: { existing, _ in existing })
var counts = StatusCounts()
let calendar = Calendar.current
let now = Date()
for lexeme in pool {
if let card = byId[lexeme.id] {
bump(&counts, interval: card.interval, dueDate: card.dueDate, now: now, calendar: calendar)
} else {
counts.new += 1
}
}
return counts
}
/// Classify one studied card into a bucket. Due-state takes precedence over
/// maturity: a mature card that's due still counts as overdue/due-today.
private static func bump(
_ counts: inout StatusCounts,
interval: Int,
dueDate: Date,
now: Date,
calendar: Calendar
) {
let today = calendar.startOfDay(for: now)
let dueDay = calendar.startOfDay(for: dueDate)
if dueDay < today {
counts.overdue += 1
} else if dueDay == today {
counts.dueToday += 1
} else if interval >= matureIntervalDays {
counts.learned += 1
} else {
counts.upcoming += 1
}
}
}
@@ -0,0 +1,69 @@
import SwiftUI
import PDFKit
struct CourseMaterialView: View {
let weekNumber: Int
let courseName: String
private var resourceName: String? {
// Only Beginner I has bundled PDFs (Beginner_I_W1.pdf Beginner_I_W8.pdf).
guard courseName.contains("Beginner I") else { return nil }
return "Beginner_I_W\(weekNumber)"
}
private var pdfURL: URL? {
guard let name = resourceName else { return nil }
return Bundle.main.url(forResource: name, withExtension: "pdf")
}
var body: some View {
Group {
if let url = pdfURL {
PDFKitView(url: url)
.ignoresSafeArea(edges: .bottom)
} else {
ContentUnavailableView(
"Material unavailable",
systemImage: "doc.questionmark",
description: Text("No course material is bundled for week \(weekNumber).")
)
}
}
.navigationTitle("Week \(weekNumber) Material")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if let url = pdfURL {
ToolbarItem(placement: .topBarTrailing) {
ShareLink(item: url) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
}
}
private struct PDFKitView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> PDFView {
let view = PDFView()
view.document = PDFDocument(url: url)
view.autoScales = true
view.displayMode = .singlePageContinuous
view.displayDirection = .vertical
view.usePageViewController(false)
return view
}
func updateUIView(_ uiView: PDFView, context: Context) {
if uiView.document?.documentURL != url {
uiView.document = PDFDocument(url: url)
}
}
}
struct CourseMaterialDestination: Hashable {
let courseName: String
let weekNumber: Int
}
@@ -57,6 +57,11 @@ struct CourseView: View {
return results.map(\.scorePercent).max() return results.map(\.scorePercent).max()
} }
private func hasCourseMaterial(for week: Int) -> Bool {
guard activeCourse.contains("Beginner I") else { return false }
return Bundle.main.url(forResource: "Beginner_I_W\(week)", withExtension: "pdf") != nil
}
private func shortName(_ full: String) -> String { private func shortName(_ full: String) -> String {
full.replacingOccurrences(of: "LanGo Spanish | ", with: "") full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
.replacingOccurrences(of: "LanGo Spanish ", with: "") .replacingOccurrences(of: "LanGo Spanish ", with: "")
@@ -116,6 +121,28 @@ struct CourseView: View {
// Week sections // Week sections
ForEach(weekGroups, id: \.week) { week, weekDecks in ForEach(weekGroups, id: \.week) { week, weekDecks in
Section { Section {
// Course material (PDF)
if hasCourseMaterial(for: week) {
NavigationLink(value: CourseMaterialDestination(courseName: activeCourse, weekNumber: week)) {
HStack(spacing: 12) {
Image(systemName: "doc.richtext")
.font(.title3)
.foregroundStyle(.purple)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Review Course Material")
.font(.subheadline.weight(.semibold))
Text("Week \(week) PDF")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
// Test button // Test button
NavigationLink(value: WeekTestDestination(courseName: activeCourse, weekNumber: week)) { NavigationLink(value: WeekTestDestination(courseName: activeCourse, weekNumber: week)) {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -231,6 +258,9 @@ struct CourseView: View {
.navigationDestination(for: TextbookExerciseDestination.self) { dest in .navigationDestination(for: TextbookExerciseDestination.self) { dest in
textbookExerciseView(for: dest) textbookExerciseView(for: dest)
} }
.navigationDestination(for: CourseMaterialDestination.self) { dest in
CourseMaterialView(weekNumber: dest.weekNumber, courseName: dest.courseName)
}
} }
} }
@@ -0,0 +1,185 @@
import SwiftUI
import SharedModels
import SwiftData
/// Due-card review for the adjective flashcard SRS non-verb analog of
/// `VocabReviewView`. Pulls every `LexemeReviewCard` with
/// `partOfSpeech == "adjective"` whose `dueDate` is in the past, shows the
/// Spanish base form on the front, reveals the English, then rates via the
/// SRS so the schedule moves forward.
struct AdjectiveReviewView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.modelContext) private var localContext
@Environment(\.dismiss) private var dismiss
@State private var dueCards: [LexemeReviewCard] = []
@State private var lexemesByID: [String: Lexeme] = [:]
@State private var currentIndex = 0
@State private var isRevealed = false
@State private var sessionCorrect = 0
@State private var sessionTotal = 0
@State private var isFinished = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
VStack(spacing: 20) {
if isFinished || dueCards.isEmpty {
finishedView
} else if let card = dueCards[safe: currentIndex] {
cardView(card)
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Adjective Review")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadDueCards)
}
@ViewBuilder
private func cardView(_ card: LexemeReviewCard) -> some View {
let lexeme = lexemesByID[card.lexemeId]
VStack(spacing: 24) {
Text("\(currentIndex + 1) of \(dueCards.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
.tint(.pink)
Spacer()
Text(lexeme?.baseForm ?? "")
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
if isRevealed {
Text(lexeme?.english ?? "")
.font(.title2)
.foregroundStyle(.secondary)
.transition(.opacity.combined(with: .move(edge: .bottom)))
Spacer()
HStack(spacing: 12) {
ratingButton("Again", color: .red, quality: .again)
ratingButton("Hard", color: .orange, quality: .hard)
ratingButton("Good", color: .green, quality: .good)
ratingButton("Easy", color: .blue, quality: .easy)
}
} else {
Spacer()
Button {
withAnimation { isRevealed = true }
} label: {
Text("Show Answer")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.pink)
}
}
}
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
.font(.system(size: 60))
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
if dueCards.isEmpty {
Text("All caught up!").font(.title2.bold())
Text("No adjective cards are due for review.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("\(sessionCorrect) / \(sessionTotal)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text("Review complete!").font(.title3).foregroundStyle(.secondary)
}
Spacer()
}
}
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
Button {
rate(quality: quality)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.bordered)
.tint(color)
}
private func rate(quality: ReviewQuality) {
guard let card = dueCards[safe: currentIndex] else { return }
ReviewStore.recordActivity(context: cloudContext)
let result = SRSEngine.review(
quality: quality,
currentEase: card.easeFactor,
currentInterval: card.interval,
currentReps: card.repetitions
)
card.easeFactor = result.easeFactor
card.interval = result.interval
card.repetitions = result.repetitions
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
card.lastReviewDate = Date()
try? cloudContext.save()
sessionTotal += 1
if quality != .again { sessionCorrect += 1 }
isRevealed = false
if currentIndex + 1 < dueCards.count {
currentIndex += 1
} else {
withAnimation { isFinished = true }
}
}
private func loadDueCards() {
let now = Date()
let pos = "adjective"
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == pos && $0.dueDate <= now
},
sortBy: [SortDescriptor(\LexemeReviewCard.dueDate)]
)
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
let ids = Set(dueCards.map(\.lexemeId))
let lexDesc = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
)
let all = (try? localContext.fetch(lexDesc)) ?? []
lexemesByID = Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
}
static func dueCount(context: ModelContext) -> Int {
let now = Date()
let pos = "adjective"
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == pos && $0.dueDate <= now
}
)
return (try? context.fetchCount(descriptor)) ?? 0
}
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -21,9 +21,18 @@ struct BookReaderView: View {
@State private var lookupCache: [String: WordAnnotation] = [:] @State private var lookupCache: [String: WordAnnotation] = [:]
/// The book's pre-computed glossary, decoded once on appear. /// The book's pre-computed glossary, decoded once on appear.
@State private var glossary: [String: WordGloss] = [:] @State private var glossary: [String: WordGloss] = [:]
/// The word long-pressed as the read-aloud start point. Session-only
/// consulted when starting fresh; cleared by long-pressing it again.
@State private var startAnchor: ReadingStart?
/// A chosen read-aloud start location: a word within a paragraph.
private struct ReadingStart: Equatable {
let paragraphIndex: Int
let wordIndex: Int
}
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = "" @AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45 @AppStorage("bookReaderRate") private var storedRate: Double = 0.50
init(chapter: BookChapter) { init(chapter: BookChapter) {
self.chapter = chapter self.chapter = chapter
@@ -70,14 +79,24 @@ struct BookReaderView: View {
} }
.accessibilityLabel("Voice & speed") .accessibilityLabel("Voice & speed")
if speech.isReading {
Button {
speech.stop()
} label: {
Image(systemName: "stop.circle")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel("Stop reading")
}
Button { Button {
toggleReadAloud() toggleReadAloud()
} label: { } label: {
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill") Image(systemName: playButtonIcon)
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
.foregroundStyle(.indigo) .foregroundStyle(.indigo)
} }
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud") .accessibilityLabel(playButtonLabel)
Button { Button {
withAnimation { showEnglish.toggle() } withAnimation { showEnglish.toggle() }
@@ -105,6 +124,7 @@ struct BookReaderView: View {
.onDisappear { .onDisappear {
speech.stop() speech.stop()
} }
.sensoryFeedback(.selection, trigger: startAnchor)
} }
@ViewBuilder @ViewBuilder
@@ -116,10 +136,11 @@ struct BookReaderView: View {
} else { } else {
TappableParagraph( TappableParagraph(
text: paragraph, text: paragraph,
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil,
) { word in startWordIndex: startAnchor?.paragraphIndex == index ? startAnchor?.wordIndex : nil,
handleTap(word: word, paragraph: paragraph) onTap: { word in handleTap(word: word, paragraph: paragraph) },
} onLongPress: { wordIndex in setStartAnchor(paragraphIndex: index, wordIndex: wordIndex) }
)
} }
} }
@@ -131,18 +152,45 @@ struct BookReaderView: View {
// MARK: - Read-along controls // MARK: - Read-along controls
/// Main read-aloud button: starts, pauses, or resumes it never stops, so
/// the reading position survives pausing (and flipping to the English
/// translation and back). Stopping is a separate button. A fresh start
/// honors the long-pressed `startAnchor` when one is set.
private func toggleReadAloud() { private func toggleReadAloud() {
if speech.isReading { if speech.isReading {
speech.stop() if speech.isPaused {
speech.resume()
} else {
speech.pause()
}
} else if let anchor = startAnchor {
speech.start(
paragraphs: paragraphsES,
fromParagraph: anchor.paragraphIndex,
word: anchor.wordIndex
)
} else { } else {
// Start from the first non-vocab paragraph at or after the topmost
// visible one. For V1 we start from the chapter top adding
// "start from visible paragraph" would need a scroll-position
// observer, which isn't worth the complexity yet.
speech.start(paragraphs: paragraphsES) speech.start(paragraphs: paragraphsES)
} }
} }
private var playButtonIcon: String {
(speech.isReading && !speech.isPaused) ? "pause.circle.fill" : "play.circle.fill"
}
private var playButtonLabel: String {
guard speech.isReading else { return "Read aloud" }
return speech.isPaused ? "Resume" : "Pause"
}
/// Long-press handler: mark this word as the start point, or clear it when
/// the already-marked word is long-pressed again. Doesn't interrupt an
/// active read-aloud the anchor is used on the next fresh start.
private func setStartAnchor(paragraphIndex: Int, wordIndex: Int) {
let candidate = ReadingStart(paragraphIndex: paragraphIndex, wordIndex: wordIndex)
startAnchor = (startAnchor == candidate) ? nil : candidate
}
private var voiceBinding: Binding<String?> { private var voiceBinding: Binding<String?> {
Binding( Binding(
get: { storedVoiceId.isEmpty ? nil : storedVoiceId }, get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
@@ -238,13 +286,21 @@ struct BookReaderView: View {
private struct TappableParagraph: View { private struct TappableParagraph: View {
let text: String let text: String
let highlightedWordIndex: Int? let highlightedWordIndex: Int?
let startWordIndex: Int?
let onTap: (String) -> Void let onTap: (String) -> Void
let onLongPress: (Int) -> Void
var body: some View { var body: some View {
let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init) let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
FlowLayout(spacing: 0) { FlowLayout(spacing: 0) {
ForEach(Array(words.enumerated()), id: \.offset) { idx, word in ForEach(Array(words.enumerated()), id: \.offset) { idx, word in
WordButton(word: word, isHighlighted: idx == highlightedWordIndex, onTap: onTap) WordButton(
word: word,
isHighlighted: idx == highlightedWordIndex,
isStartAnchor: idx == startWordIndex,
onTap: onTap,
onLongPress: { onLongPress(idx) }
)
} }
} }
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
@@ -254,7 +310,9 @@ private struct TappableParagraph: View {
private struct WordButton: View { private struct WordButton: View {
let word: String let word: String
let isHighlighted: Bool let isHighlighted: Bool
let isStartAnchor: Bool
let onTap: (String) -> Void let onTap: (String) -> Void
let onLongPress: () -> Void
var body: some View { var body: some View {
Button { Button {
@@ -263,17 +321,26 @@ private struct WordButton: View {
Text(word + " ") Text(word + " ")
.font(.body) .font(.body)
.foregroundStyle(.primary) .foregroundStyle(.primary)
.padding(.horizontal, isHighlighted ? 2 : 0) .padding(.horizontal, (isHighlighted || isStartAnchor) ? 2 : 0)
.padding(.vertical, 1) .padding(.vertical, 1)
.background( .background(backgroundColor, in: RoundedRectangle(cornerRadius: 4))
isHighlighted
? Color.yellow.opacity(0.35)
: Color.clear,
in: RoundedRectangle(cornerRadius: 4)
)
.animation(.easeInOut(duration: 0.15), value: isHighlighted) .animation(.easeInOut(duration: 0.15), value: isHighlighted)
.animation(.easeInOut(duration: 0.15), value: isStartAnchor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
// Long-press marks the read-aloud start; the Button's tap still defines
// the word. simultaneousGesture lets both live on the same view.
.simultaneousGesture(
LongPressGesture(minimumDuration: 0.4).onEnded { _ in onLongPress() }
)
}
/// The active spoken word (yellow) takes precedence over the start marker
/// (indigo) so a word that's both reads as "now speaking."
private var backgroundColor: Color {
if isHighlighted { return Color.yellow.opacity(0.35) }
if isStartAnchor { return Color.indigo.opacity(0.20) }
return Color.clear
} }
} }
@@ -42,11 +42,13 @@ struct BookVoicePickerSheet: View {
Form { Form {
Section("Speed") { Section("Speed") {
Picker("Speed", selection: $rate) { Picker("Speed", selection: $rate) {
Text("Slow").tag(Float(0.40)) Text("0.5×").tag(Float(0.30))
Text("Normal").tag(Float(0.50)) Text("0.75×").tag(Float(0.40))
Text("Fast").tag(Float(0.55)) Text("1×").tag(Float(0.50))
Text("1.25×").tag(Float(0.575))
Text("1.5×").tag(Float(0.65))
} }
.pickerStyle(.segmented) .pickerStyle(.menu)
} }
if groups.isEmpty { if groups.isEmpty {
@@ -0,0 +1,197 @@
import SwiftUI
import SharedModels
import SwiftData
/// Due-card review for the noun flashcard SRS the non-verb analog of
/// `VocabReviewView`. Pulls every `LexemeReviewCard` with
/// `partOfSpeech == "noun"` whose `dueDate` is in the past, shows the
/// Spanish word with its article on the front, reveals the English, then
/// rates via the SRS so the schedule moves forward.
struct NounReviewView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.modelContext) private var localContext
@Environment(\.dismiss) private var dismiss
@State private var dueCards: [LexemeReviewCard] = []
@State private var lexemesByID: [String: Lexeme] = [:]
@State private var currentIndex = 0
@State private var isRevealed = false
@State private var sessionCorrect = 0
@State private var sessionTotal = 0
@State private var isFinished = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
VStack(spacing: 20) {
if isFinished || dueCards.isEmpty {
finishedView
} else if let card = dueCards[safe: currentIndex] {
cardView(card)
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Noun Review")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadDueCards)
}
@ViewBuilder
private func cardView(_ card: LexemeReviewCard) -> some View {
let lexeme = lexemesByID[card.lexemeId]
VStack(spacing: 24) {
Text("\(currentIndex + 1) of \(dueCards.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
.tint(.teal)
Spacer()
Text(spanishFront(lexeme))
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
if isRevealed {
Text(lexeme?.english ?? "")
.font(.title2)
.foregroundStyle(.secondary)
.transition(.opacity.combined(with: .move(edge: .bottom)))
Spacer()
HStack(spacing: 12) {
ratingButton("Again", color: .red, quality: .again)
ratingButton("Hard", color: .orange, quality: .hard)
ratingButton("Good", color: .green, quality: .good)
ratingButton("Easy", color: .blue, quality: .easy)
}
} else {
Spacer()
Button {
withAnimation { isRevealed = true }
} label: {
Text("Show Answer")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.teal)
}
}
}
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
.font(.system(size: 60))
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
if dueCards.isEmpty {
Text("All caught up!").font(.title2.bold())
Text("No noun cards are due for review.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("\(sessionCorrect) / \(sessionTotal)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text("Review complete!").font(.title3).foregroundStyle(.secondary)
}
Spacer()
}
}
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
Button {
rate(quality: quality)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.bordered)
.tint(color)
}
private func rate(quality: ReviewQuality) {
guard let card = dueCards[safe: currentIndex] else { return }
ReviewStore.recordActivity(context: cloudContext)
let result = SRSEngine.review(
quality: quality,
currentEase: card.easeFactor,
currentInterval: card.interval,
currentReps: card.repetitions
)
card.easeFactor = result.easeFactor
card.interval = result.interval
card.repetitions = result.repetitions
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
card.lastReviewDate = Date()
try? cloudContext.save()
sessionTotal += 1
if quality != .again { sessionCorrect += 1 }
isRevealed = false
if currentIndex + 1 < dueCards.count {
currentIndex += 1
} else {
withAnimation { isFinished = true }
}
}
private func loadDueCards() {
let now = Date()
let pos = "noun"
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == pos && $0.dueDate <= now
},
sortBy: [SortDescriptor(\LexemeReviewCard.dueDate)]
)
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
let ids = Set(dueCards.map(\.lexemeId))
let lexDesc = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
)
let all = (try? localContext.fetch(lexDesc)) ?? []
lexemesByID = Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
}
static func dueCount(context: ModelContext) -> Int {
let now = Date()
let pos = "noun"
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == pos && $0.dueDate <= now
}
)
return (try? context.fetchCount(descriptor)) ?? 0
}
private func spanishFront(_ lexeme: Lexeme?) -> String {
guard let lexeme else { return "" }
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
let article: String
switch g {
case "f": article = "la"
case "m/f": article = "el/la"
default: article = "el"
}
return "\(article) \(lexeme.baseForm)"
}
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -9,6 +9,12 @@ struct PracticeView: View {
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
@State private var isPracticing = false @State private var isPracticing = false
@State private var userProgress: UserProgress? @State private var userProgress: UserProgress?
/// Cached due counts for the noun + adjective Review rows. Refreshed on
/// appear, on session end (`isPracticing` change), and after the user
/// returns from a Review screen. Avoids running `fetchCount` against the
/// cloud context on every `body` re-evaluation.
@State private var nounDueCount: Int = 0
@State private var adjectiveDueCount: Int = 0
private var cloudModelContext: ModelContext { cloudModelContextProvider() } private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@@ -36,10 +42,14 @@ struct PracticeView: View {
} }
.navigationTitle("Practice") .navigationTitle("Practice")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadProgress) .onAppear {
loadProgress()
refreshLexemeDueCounts()
}
.onChange(of: isPracticing) { _, practicing in .onChange(of: isPracticing) { _, practicing in
if !practicing { if !practicing {
loadProgress() loadProgress()
refreshLexemeDueCounts()
} }
} }
.toolbar { .toolbar {
@@ -115,6 +125,14 @@ struct PracticeView: View {
sectionHeader("Vocabulary") sectionHeader("Vocabulary")
vocabSection vocabSection
// === Section: Nouns ===
sectionHeader("Nouns")
nounsSection
// === Section: Adjectives ===
sectionHeader("Adjectives")
adjectivesSection
// === Section: Reading === // === Section: Reading ===
sectionHeader("Reading") sectionHeader("Reading")
@@ -421,12 +439,22 @@ struct PracticeView: View {
VocabFlashcardPracticeView(kind: .reviewLearned) VocabFlashcardPracticeView(kind: .reviewLearned)
} label: { } label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple, practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
title: "Review Learned", title: "Review Learned — Flashcards",
subtitle: "Re-review verbs you've studied — schedule unchanged") subtitle: "Re-review verbs you've studied — schedule unchanged")
} }
.tint(.primary) .tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
VocabMultipleChoicePracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
title: "Review Learned — Multiple Choice",
subtitle: "Multiple choice over verbs you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Existing: Vocab Review (due cards) // Existing: Vocab Review (due cards)
NavigationLink { NavigationLink {
VocabReviewView() VocabReviewView()
@@ -470,6 +498,176 @@ struct PracticeView: View {
.padding(.horizontal) .padding(.horizontal)
} }
// MARK: - Nouns section
private var nounsSection: some View {
VStack(spacing: 12) {
NavigationLink {
NounFlashcardPracticeView()
} label: {
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .teal,
title: "Noun Flashcards",
subtitle: "English → Spanish noun (with article)")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
NounMultipleChoicePracticeView()
} label: {
practiceRowLabel(icon: "checklist", color: .teal,
title: "Noun Multiple Choice",
subtitle: "Pick the Spanish noun from 4 options")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
NounFlashcardPracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal,
title: "Review Learned — Flashcards",
subtitle: "Re-review nouns you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
NounMultipleChoicePracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal,
title: "Review Learned — Multiple Choice",
subtitle: "Multiple choice over nouns you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
NounReviewView()
} label: {
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.teal)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Noun Review")
.font(.subheadline.weight(.semibold))
Text("Review due noun cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if nounDueCount > 0 {
Text("\(nounDueCount)")
.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)
}
// MARK: - Adjectives section
private var adjectivesSection: some View {
VStack(spacing: 12) {
NavigationLink {
AdjectiveFlashcardPracticeView()
} label: {
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .pink,
title: "Adjective Flashcards",
subtitle: "English → Spanish adjective base form")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
AdjectiveMultipleChoicePracticeView()
} label: {
practiceRowLabel(icon: "checklist", color: .pink,
title: "Adjective Multiple Choice",
subtitle: "Pick the Spanish adjective from 4 options")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
AdjectiveFlashcardPracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink,
title: "Review Learned — Flashcards",
subtitle: "Re-review adjectives you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
AdjectiveMultipleChoicePracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink,
title: "Review Learned — Multiple Choice",
subtitle: "Multiple choice over adjectives you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
AdjectiveReviewView()
} label: {
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.pink)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Adjective Review")
.font(.subheadline.weight(.semibold))
Text("Review due adjective cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if adjectiveDueCount > 0 {
Text("\(adjectiveDueCount)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.pink, 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 { private func practiceRowLabel(icon: String, color: Color, title: String, subtitle: String) -> some View {
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: icon) Image(systemName: icon)
@@ -613,6 +811,11 @@ extension PracticeView {
withAnimation { isPracticing = true } withAnimation { isPracticing = true }
} }
private func refreshLexemeDueCounts() {
nounDueCount = NounReviewView.dueCount(context: cloudModelContext)
adjectiveDueCount = AdjectiveReviewView.dueCount(context: cloudModelContext)
}
private func loadProgress() { private func loadProgress() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
userProgress = progress userProgress = progress
@@ -0,0 +1,310 @@
import SwiftUI
import SharedModels
import SwiftData
/// English Spanish adjective flashcards. Same flow as the noun view and
/// the verb flashcards: show the English meaning, tap to reveal the Spanish
/// base form, rate Again/Hard/Good/Easy. Agreement (gender + number) is
/// taught organically through reading and verb-flashcard examples, not as a
/// separate quiz here.
///
/// Plain `ScrollView { VStack }` no `LazyVStack`/`ScrollViewReader`.
struct AdjectiveFlashcardPracticeView: View {
var kind: LexemeSessionKind = .standard
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var session: LexemeSessionQueue?
@State private var revealed: Bool = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentLexeme: Lexeme? { session?.current?.lexeme }
private static let drillMode = "recall"
var body: some View {
ScrollView {
VStack(spacing: 24) {
headerBar
if let lexeme = currentLexeme {
cardContent(for: lexeme)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Adjectives")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: revealed)
.animation(.smooth, value: currentLexeme?.id)
}
// MARK: - Card
@ViewBuilder
private func cardContent(for lexeme: Lexeme) -> some View {
Text(lexeme.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if revealed {
VStack(spacing: 14) {
Text(lexeme.baseForm)
.font(.title.weight(.semibold))
.multilineTextAlignment(.center)
exampleBlock(for: lexeme)
ratingButtons(for: lexeme)
}
} 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, minHeight: 200)
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.smooth) { revealed = true }
}
}
@ViewBuilder
private func exampleBlock(for lexeme: Lexeme) -> some View {
if let es = lexeme.exampleES, !es.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(es).font(.subheadline).italic()
if let en = lexeme.exampleEN, !en.isEmpty {
Text(en).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Header
@ViewBuilder
private var headerBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0).tint(.pink)
Text(progressLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var progressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Rating
private func ratingButtons(for lexeme: Lexeme) -> some View {
VStack(spacing: 10) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, rating: .again, lexeme: lexeme)
ratingButton("Hard", color: .orange, rating: .hard, lexeme: lexeme)
ratingButton("Good", color: .green, rating: .good, lexeme: lexeme)
ratingButton("Easy", color: .blue, rating: .easy, lexeme: lexeme)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating, lexeme: Lexeme) -> some View {
Button {
answer(rating, for: lexeme)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
let graduation = session?.answer(rating)
if let graduation, kind == .standard {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "adjective",
drillMode: Self.drillMode,
quality: graduation
)
}
persistGroup()
withAnimation(.smooth) { revealed = false }
}
// MARK: - Completion
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56)).foregroundStyle(.green)
Text(completionTitle).font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button { studyAgain() } label: {
Label("Next Set", systemImage: "arrow.right")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.pink)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionTitle: String {
let learned = session?.learnedCount ?? 0
return learned > 0 ? "Session Complete" : "Nothing Available"
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
return "\(learned) adjective\(learned == 1 ? "" : "s") learned"
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
if progress.selectedLexemeLevels.isEmpty {
return "No vocabulary levels enabled. Open Settings → Vocabulary Levels to turn some on."
}
return "No adjectives available at the enabled levels right now."
}
// MARK: - Session lifecycle
private func loadIfNeeded() {
guard session == nil else { return }
switch kind {
case .reviewLearned:
let lexemes = LexemePool.reviewLearnedLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
return
case .standard:
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
drillMode: Self.drillMode
)
if let group = store.activeGroup() {
let stored = group.entries
if !stored.isEmpty {
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
guard let lex = byId[e.lexemeId] else { return nil }
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
}
if entries.count == stored.count {
session = LexemeSessionQueue(
entries: entries,
drillMode: Self.drillMode,
learnedCount: group.learnedCount
)
return
}
}
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
}
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
let descriptor = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
)
let all = (try? localContext.fetch(descriptor)) ?? []
return Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
}
private func persistGroup() {
guard kind == .standard, let session else { return }
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
drillMode: Self.drillMode
)
if session.isComplete {
store.clear()
} else {
let entries = session.snapshot().map {
StoredLexemeEntry(lexemeId: $0.lexemeId, state: $0.state.rawValue)
}
store.persist(entries: entries, learnedCount: session.learnedCount)
}
}
private func studyAgain() {
switch kind {
case .reviewLearned:
session?.restart()
case .standard:
LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
drillMode: Self.drillMode
).clear()
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
revealed = false
}
}
@@ -0,0 +1,257 @@
import SwiftUI
import SharedModels
import SwiftData
/// English-first adjective multiple choice non-verb analog of
/// `VocabMultipleChoicePracticeView`. Driven by `LexemeSessionQueue` over the
/// adjective pool; 4 options (1 correct + 3 random distractors from the
/// session). Options are bare base forms agreement isn't drilled here.
struct AdjectiveMultipleChoicePracticeView: View {
var kind: LexemeSessionKind = .standard
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var session: LexemeSessionQueue?
@State private var distractorPool: [Lexeme] = []
@State private var options: [Lexeme] = []
@State private var selectedOption: Lexeme? = nil
private static let drillMode = "recall"
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentLexeme: Lexeme? { session?.current?.lexeme }
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let lexeme = currentLexeme {
questionBody(lexeme)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Adjective Multiple Choice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id)
.animation(.smooth, value: currentLexeme?.id)
}
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0).tint(.pink)
Text(progressLabel).font(.caption).foregroundStyle(.secondary)
}
}
private var progressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
@ViewBuilder
private func questionBody(_ lexeme: Lexeme) -> some View {
Text(lexeme.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if selectedOption == nil {
optionGrid
} else {
revealedContent(lexeme)
}
}
private var optionGrid: some View {
VStack(spacing: 10) {
ForEach(options, id: \.id) { option in
Button {
selectedOption = option
} label: {
Text(option.baseForm)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.tint(.primary)
}
}
}
private func revealedContent(_ lexeme: Lexeme) -> some View {
VStack(spacing: 16) {
answerFeedback(lexeme)
exampleBlock(for: lexeme)
ratingButtons
}
}
private func answerFeedback(_ lexeme: Lexeme) -> some View {
let correct = (selectedOption?.id == lexeme.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(lexeme.baseForm)
.font(.title2.weight(.semibold))
.padding(.top, 4)
}
}
@ViewBuilder
private func exampleBlock(for lexeme: Lexeme) -> some View {
if let es = lexeme.exampleES, !es.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(es).font(.subheadline).italic()
if let en = lexeme.exampleEN, !en.isEmpty {
Text(en).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
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, rating: .again)
ratingButton("Hard", color: .orange, rating: .hard)
ratingButton("Good", color: .green, rating: .good)
ratingButton("Easy", color: .blue, rating: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating) -> some View {
Button {
answer(rating)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
.font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button { studyAgain() } label: {
Label("Study Again", systemImage: "arrow.clockwise")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.pink)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
let verb = kind == .reviewLearned ? "reviewed" : "learned"
return "\(learned) adjective\(learned == 1 ? "" : "s") \(verb)"
}
switch kind {
case .standard:
return "No adjectives are due right now. Study Again to review anyway."
case .reviewLearned:
return "Finish an adjective session first, then come back to consolidate."
}
}
private func loadIfNeeded() {
guard session == nil else { return }
let lexemes: [Lexeme]
switch kind {
case .standard:
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
lexemes = LexemePool.sessionLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
case .reviewLearned:
lexemes = LexemePool.reviewLearnedLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
localContext: localContext,
cloudContext: cloudContext
)
}
distractorPool = lexemes
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
prepareOptions()
}
private func studyAgain() {
session?.restart()
selectedOption = nil
prepareOptions()
}
private func prepareOptions() {
guard let lexeme = currentLexeme else { options = []; return }
let candidates = distractorPool.filter { $0.id != lexeme.id }
let distractors = Array(candidates.shuffled().prefix(3))
options = ([lexeme] + distractors).shuffled()
}
private func answer(_ rating: LexemeSessionQueue.Rating) {
guard let lexeme = currentLexeme else { return }
let graduation = session?.answer(rating)
// Review Learned is a cram pass graduation drives the in-session
// queue only; the long-term schedule is left untouched.
if let graduation, kind == .standard {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "adjective",
drillMode: Self.drillMode,
quality: graduation
)
}
selectedOption = nil
prepareOptions()
}
}
@@ -0,0 +1,332 @@
import SwiftUI
import SharedModels
import SwiftData
/// English Spanish noun flashcards. Same flow as `VocabFlashcardPracticeView`
/// for verbs: show the English meaning, tap to reveal the Spanish word, rate
/// Again/Hard/Good/Easy. The Spanish reveal shows the word with its article
/// (`la taza`, `el problema`) so gender is taught alongside meaning instead
/// of being a separate "el or la?" quiz.
///
/// Plain `ScrollView { VStack }` no `LazyVStack`/`ScrollViewReader` (keeps
/// it out of the books-reader layout-loop class of bug).
struct NounFlashcardPracticeView: View {
var kind: LexemeSessionKind = .standard
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var session: LexemeSessionQueue?
@State private var revealed: Bool = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentLexeme: Lexeme? { session?.current?.lexeme }
/// Single drill mode for now meaning recall. The `LexemeReviewCard` /
/// `LexemeStudyGroup` IDs are keyed by drillMode so other modes can be
/// added later without colliding with this one.
private static let drillMode = "recall"
var body: some View {
ScrollView {
VStack(spacing: 24) {
headerBar
if let lexeme = currentLexeme {
cardContent(for: lexeme)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Nouns")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: revealed)
.animation(.smooth, value: currentLexeme?.id)
}
// MARK: - Card
@ViewBuilder
private func cardContent(for lexeme: Lexeme) -> some View {
Text(lexeme.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if revealed {
VStack(spacing: 14) {
Text(formattedSpanish(lexeme))
.font(.title.weight(.semibold))
.multilineTextAlignment(.center)
exampleBlock(for: lexeme)
ratingButtons(for: lexeme)
}
} else {
tapToReveal
}
}
/// Show the noun with its article so gender comes along free.
private func formattedSpanish(_ lexeme: Lexeme) -> String {
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
let article: String
switch g {
case "f": article = "la"
case "m/f": article = "el/la"
default: article = "el"
}
return "\(article) \(lexeme.baseForm)"
}
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, minHeight: 200)
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.smooth) { revealed = true }
}
}
@ViewBuilder
private func exampleBlock(for lexeme: Lexeme) -> some View {
if let es = lexeme.exampleES, !es.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(es).font(.subheadline).italic()
if let en = lexeme.exampleEN, !en.isEmpty {
Text(en).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Header
@ViewBuilder
private var headerBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0).tint(.teal)
Text(progressLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var progressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Rating
private func ratingButtons(for lexeme: Lexeme) -> some View {
VStack(spacing: 10) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, rating: .again, lexeme: lexeme)
ratingButton("Hard", color: .orange, rating: .hard, lexeme: lexeme)
ratingButton("Good", color: .green, rating: .good, lexeme: lexeme)
ratingButton("Easy", color: .blue, rating: .easy, lexeme: lexeme)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating, lexeme: Lexeme) -> some View {
Button {
answer(rating, for: lexeme)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
let graduation = session?.answer(rating)
// Review Learned is a cram graduation drives the in-session queue
// only; the cross-session SM-2 schedule is left alone (mirrors the
// verb VocabFlashcardPracticeView reviewLearned behavior).
if let graduation, kind == .standard {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "noun",
drillMode: Self.drillMode,
quality: graduation
)
}
persistGroup()
withAnimation(.smooth) { revealed = false }
}
// MARK: - Completion
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56)).foregroundStyle(.green)
Text(completionTitle).font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button { studyAgain() } label: {
Label("Next Set", systemImage: "arrow.right")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.teal)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionTitle: String {
let learned = session?.learnedCount ?? 0
return learned > 0 ? "Session Complete" : "Nothing Available"
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
return "\(learned) noun\(learned == 1 ? "" : "s") learned"
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
if progress.selectedLexemeLevels.isEmpty {
return "No vocabulary levels enabled. Open Settings → Vocabulary Levels to turn some on."
}
return "No nouns available at the enabled levels right now."
}
// MARK: - Session lifecycle
private func loadIfNeeded() {
guard session == nil else { return }
switch kind {
case .reviewLearned:
// Cram pass over previously-studied lexemes. No study-group
// persistence restart-fresh each time it opens.
let lexemes = LexemePool.reviewLearnedLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
return
case .standard:
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
drillMode: Self.drillMode
)
if let group = store.activeGroup() {
let stored = group.entries
if !stored.isEmpty {
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
guard let lex = byId[e.lexemeId] else { return nil }
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
}
if entries.count == stored.count {
session = LexemeSessionQueue(
entries: entries,
drillMode: Self.drillMode,
learnedCount: group.learnedCount
)
return
}
}
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
}
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
let descriptor = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
)
let all = (try? localContext.fetch(descriptor)) ?? []
return Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
}
private func persistGroup() {
// Review Learned is a transient cram; don't write a study group.
guard kind == .standard, let session else { return }
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
drillMode: Self.drillMode
)
if session.isComplete {
store.clear()
} else {
let entries = session.snapshot().map {
StoredLexemeEntry(lexemeId: $0.lexemeId, state: $0.state.rawValue)
}
store.persist(entries: entries, learnedCount: session.learnedCount)
}
}
private func studyAgain() {
switch kind {
case .reviewLearned:
session?.restart()
case .standard:
LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
drillMode: Self.drillMode
).clear()
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
revealed = false
}
}
@@ -0,0 +1,278 @@
import SwiftUI
import SharedModels
import SwiftData
/// English-first noun multiple choice non-verb analog of
/// `VocabMultipleChoicePracticeView`. Driven by `LexemeSessionQueue` over the
/// noun pool; 4 options (1 correct + 3 random distractors from the session).
/// After answering: reveal feedback, the answer with its article (la taza /
/// el problema), example sentence when present, and Again/Hard/Good/Easy
/// rating which drives the `LexemeReviewStore` schedule.
struct NounMultipleChoicePracticeView: View {
var kind: LexemeSessionKind = .standard
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var session: LexemeSessionQueue?
@State private var distractorPool: [Lexeme] = []
@State private var options: [Lexeme] = []
@State private var selectedOption: Lexeme? = nil
private static let drillMode = "recall"
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentLexeme: Lexeme? { session?.current?.lexeme }
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let lexeme = currentLexeme {
questionBody(lexeme)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Noun Multiple Choice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id)
.animation(.smooth, value: currentLexeme?.id)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0).tint(.teal)
Text(progressLabel).font(.caption).foregroundStyle(.secondary)
}
}
private var progressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Question
@ViewBuilder
private func questionBody(_ lexeme: Lexeme) -> some View {
Text(lexeme.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if selectedOption == nil {
optionGrid
} else {
revealedContent(lexeme)
}
}
private var optionGrid: some View {
VStack(spacing: 10) {
ForEach(options, id: \.id) { option in
Button {
selectedOption = option
} label: {
Text(formattedSpanish(option))
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.tint(.primary)
}
}
}
private func revealedContent(_ lexeme: Lexeme) -> some View {
VStack(spacing: 16) {
answerFeedback(lexeme)
exampleBlock(for: lexeme)
ratingButtons
}
}
private func answerFeedback(_ lexeme: Lexeme) -> some View {
let correct = (selectedOption?.id == lexeme.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(formattedSpanish(lexeme))
.font(.title2.weight(.semibold))
.padding(.top, 4)
}
}
@ViewBuilder
private func exampleBlock(for lexeme: Lexeme) -> some View {
if let es = lexeme.exampleES, !es.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(es).font(.subheadline).italic()
if let en = lexeme.exampleEN, !en.isEmpty {
Text(en).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
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, rating: .again)
ratingButton("Hard", color: .orange, rating: .hard)
ratingButton("Good", color: .green, rating: .good)
ratingButton("Easy", color: .blue, rating: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating) -> some View {
Button {
answer(rating)
} 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?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
.font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button { studyAgain() } label: {
Label("Study Again", systemImage: "arrow.clockwise")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.teal)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
let verb = kind == .reviewLearned ? "reviewed" : "learned"
return "\(learned) noun\(learned == 1 ? "" : "s") \(verb)"
}
switch kind {
case .standard:
return "No nouns are due right now. Study Again to review anyway."
case .reviewLearned:
return "Finish a noun session first, then come back to consolidate."
}
}
// MARK: - Logic
private func loadIfNeeded() {
guard session == nil else { return }
let lexemes: [Lexeme]
switch kind {
case .standard:
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
case .reviewLearned:
lexemes = LexemePool.reviewLearnedLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
localContext: localContext,
cloudContext: cloudContext
)
}
distractorPool = lexemes
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
prepareOptions()
}
private func studyAgain() {
session?.restart()
selectedOption = nil
prepareOptions()
}
private func prepareOptions() {
guard let lexeme = currentLexeme else { options = []; return }
let candidates = distractorPool.filter { $0.id != lexeme.id }
let distractors = Array(candidates.shuffled().prefix(3))
options = ([lexeme] + distractors).shuffled()
}
private func answer(_ rating: LexemeSessionQueue.Rating) {
guard let lexeme = currentLexeme else { return }
let graduation = session?.answer(rating)
// Review Learned is a cram pass graduation drives the in-session
// queue only; the long-term schedule is left untouched.
if let graduation, kind == .standard {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "noun",
drillMode: Self.drillMode,
quality: graduation
)
}
selectedOption = nil
prepareOptions()
}
private func formattedSpanish(_ lexeme: Lexeme) -> String {
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
let article: String
switch g {
case "f": article = "la"
case "m/f": article = "el/la"
default: article = "el"
}
return "\(article) \(lexeme.baseForm)"
}
}
@@ -7,6 +7,8 @@ import SwiftData
/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS /// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS
/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates. /// rating buttons. Again/Hard requeue; a second Good or an Easy graduates.
struct VocabMultipleChoicePracticeView: View { struct VocabMultipleChoicePracticeView: View {
var kind: VocabSessionKind = .standard
@Environment(\.modelContext) private var localContext @Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VerbExampleCache.self) private var exampleCache @Environment(VerbExampleCache.self) private var exampleCache
@@ -37,7 +39,7 @@ struct VocabMultipleChoicePracticeView: View {
.padding() .padding()
.adaptiveContainer(maxWidth: 720) .adaptiveContainer(maxWidth: 720)
} }
.navigationTitle("Vocab Multiple Choice") .navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Multiple Choice")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded) .onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id) .animation(.smooth, value: selectedOption?.id)
@@ -221,16 +223,28 @@ struct VocabMultipleChoicePracticeView: View {
private var completionDetail: String { private var completionDetail: String {
let learned = session?.learnedCount ?? 0 let learned = session?.learnedCount ?? 0
if learned > 0 { if learned > 0 {
return "\(learned) verb\(learned == 1 ? "" : "s") learned" let verb = kind == .reviewLearned ? "reviewed" : "learned"
return "\(learned) verb\(learned == 1 ? "" : "s") \(verb)"
}
switch kind {
case .standard:
return "No verbs are due right now. Study Again to review anyway."
case .reviewLearned:
return "Finish a Vocab session first, then come back to consolidate."
} }
return "No verbs are due right now. Study Again to review anyway."
} }
// MARK: - Logic // MARK: - Logic
private func loadIfNeeded() { private func loadIfNeeded() {
guard session == nil else { return } guard session == nil else { return }
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) let verbs: [Verb]
switch kind {
case .standard:
verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
case .reviewLearned:
verbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
}
distractorPool = verbs distractorPool = verbs
session = VocabSessionQueue(verbs: verbs) session = VocabSessionQueue(verbs: verbs)
prepareOptions() prepareOptions()
@@ -254,7 +268,9 @@ struct VocabMultipleChoicePracticeView: View {
private func answer(_ rating: VocabSessionQueue.Rating) { private func answer(_ rating: VocabSessionQueue.Rating) {
guard let verbId = currentVerb?.id else { return } guard let verbId = currentVerb?.id else { return }
let graduation = session?.answer(rating) ?? nil let graduation = session?.answer(rating) ?? nil
if let graduation { // Review Learned is a cram pass graduation drives the in-session
// queue only; the long-term schedule is left untouched.
if let graduation, kind == .standard {
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation) VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
} }
selectedOption = nil selectedOption = nil
@@ -4,16 +4,33 @@ import SwiftData
struct SettingsView: View { struct SettingsView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
/// Local reference store (verbs, lexemes) needed for the status metrics.
@Environment(\.modelContext) private var localContext
@State private var progress: UserProgress? @State private var progress: UserProgress?
@State private var dailyGoal: Double = 50 @State private var dailyGoal: Double = 50
@State private var showVosotros: Bool = true @State private var showVosotros: Bool = true
@State private var autoFillStem: Bool = false @State private var autoFillStem: Bool = false
/// Cards per vocab-practice session. 999 = "All" (no cap). /// Cards per study session, per word type. 999 = "All" (no cap).
@AppStorage("vocabSessionCardLimit") private var vocabSessionCardLimit: Int = 20 @AppStorage("vocabSessionCardLimit") private var vocabSessionCardLimit: Int = 20
@AppStorage("nounSessionCardLimit") private var nounSessionCardLimit: Int = 20
@AppStorage("adjectiveSessionCardLimit") private var adjectiveSessionCardLimit: Int = 20
private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999] private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999]
/// New (never-studied) words introduced per session, per type. 0 =
/// review-only; 999 = "All" (no throttle).
@AppStorage("vocabNewWordsPerSession") private var vocabNewWordsPerSession: Int = 10
@AppStorage("nounNewWordsPerSession") private var nounNewWordsPerSession: Int = 10
@AppStorage("adjectiveNewWordsPerSession") private var adjectiveNewWordsPerSession: Int = 10
private let newWordsSizes: [Int] = [0, 5, 10, 15, 20, 999]
/// SRS status breakdowns, scoped to the enabled levels. Recomputed on
/// appear and whenever the level toggles change.
@State private var verbStatus = StatusCounts()
@State private var nounStatus = StatusCounts()
@State private var adjectiveStatus = StatusCounts()
private let levels = VerbLevel.allCases private let levels = VerbLevel.allCases
private let irregularCategories: [IrregularSpan.SpanCategory] = [ private let irregularCategories: [IrregularSpan.SpanCategory] = [
.spelling, .stemChange, .uniqueIrregular .spelling, .stemChange, .uniqueIrregular
@@ -47,15 +64,23 @@ struct SettingsView: View {
} }
Section { Section {
Picker("Cards per session", selection: $vocabSessionCardLimit) { sessionSizePicker("Verbs per session", selection: $vocabSessionCardLimit)
ForEach(vocabSessionSizes, id: \.self) { size in sessionSizePicker("Nouns per session", selection: $nounSessionCardLimit)
Text(size == 999 ? "All" : "\(size)").tag(size) sessionSizePicker("Adjectives per session", selection: $adjectiveSessionCardLimit)
}
}
} header: { } header: {
Text("Vocab Flashcards") Text("Cards Per Session")
} footer: { } footer: {
Text("How many verbs a Vocab Flashcards session draws. Overdue verbs are pulled first, then new ones.") Text("How many cards each flashcard or multiple-choice session draws, per word type. Overdue cards are pulled first, then new ones.")
}
Section {
newWordsPicker("New verbs per session", selection: $vocabNewWordsPerSession)
newWordsPicker("New nouns per session", selection: $nounNewWordsPerSession)
newWordsPicker("New adjectives per session", selection: $adjectiveNewWordsPerSession)
} header: {
Text("New Words Per Session")
} footer: {
Text("How many brand-new words a session introduces. Overdue reviews are shown first; new words fill whatever room is left, up to this number. 0 = reviews only.")
} }
Section { Section {
@@ -68,15 +93,56 @@ struct SettingsView: View {
guard let progress else { return } guard let progress else { return }
progress.setLevelEnabled(level, enabled: enabled) progress.setLevelEnabled(level, enabled: enabled)
saveProgress() saveProgress()
refreshMetrics()
} }
)) ))
} }
} header: { } header: {
Text("Levels") Text("Verb Levels")
} footer: { } footer: {
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.") Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
} }
Section {
statusRows(verbStatus)
} header: {
Text("Verb Status")
} footer: {
Text("Counts reflect only the verb levels enabled above.")
}
Section {
ForEach(LexemeLevel.allCases, id: \.self) { level in
Toggle(level.displayName, isOn: Binding(
get: {
progress?.selectedLexemeLevels.contains(level) ?? false
},
set: { enabled in
guard let progress else { return }
progress.setLexemeLevelEnabled(level, enabled: enabled)
saveProgress()
refreshMetrics()
}
))
}
} header: {
Text("Vocabulary Levels")
} footer: {
Text("Noun and adjective flashcards pull only from the enabled CEFR levels. New first-time installs default to A1 + A2.")
}
Section {
statusRows(nounStatus)
} header: {
Text("Noun Status")
} footer: {
Text("Counts reflect only the vocabulary levels enabled above.")
}
Section("Adjective Status") {
statusRows(adjectiveStatus)
}
Section { Section {
ForEach(TenseInfo.all) { tense in ForEach(TenseInfo.all) { tense in
Toggle(tense.english, isOn: Binding( Toggle(tense.english, isOn: Binding(
@@ -153,12 +219,61 @@ struct SettingsView: View {
} }
} }
private func sessionSizePicker(_ title: String, selection: Binding<Int>) -> some View {
Picker(title, selection: selection) {
ForEach(vocabSessionSizes, id: \.self) { size in
Text(size == 999 ? "All" : "\(size)").tag(size)
}
}
}
private func newWordsPicker(_ title: String, selection: Binding<Int>) -> some View {
Picker(title, selection: selection) {
ForEach(newWordsSizes, id: \.self) { size in
Text(size == 999 ? "All" : "\(size)").tag(size)
}
}
}
@ViewBuilder
private func statusRows(_ counts: StatusCounts) -> some View {
LabeledContent("New", value: "\(counts.new)")
LabeledContent("Overdue", value: "\(counts.overdue)")
LabeledContent("Due today", value: "\(counts.dueToday)")
LabeledContent("Upcoming", value: "\(counts.upcoming)")
LabeledContent("Learned", value: "\(counts.learned)")
LabeledContent("Total", value: "\(counts.total)")
.foregroundStyle(.secondary)
}
private func loadProgress() { private func loadProgress() {
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress = resolved progress = resolved
dailyGoal = Double(resolved.dailyGoal) dailyGoal = Double(resolved.dailyGoal)
showVosotros = resolved.showVosotros showVosotros = resolved.showVosotros
autoFillStem = resolved.autoFillStem autoFillStem = resolved.autoFillStem
refreshMetrics()
}
private func refreshMetrics() {
guard let progress else { return }
verbStatus = WordStatusMetrics.verbCounts(
selectedLevels: progress.selectedVerbLevels,
localContext: localContext,
cloudContext: cloudModelContext
)
nounStatus = WordStatusMetrics.lexemeCounts(
partOfSpeech: "noun",
selectedLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudModelContext
)
adjectiveStatus = WordStatusMetrics.lexemeCounts(
partOfSpeech: "adjective",
selectedLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudModelContext
)
} }
private func saveProgress() { private func saveProgress() {
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -60,10 +60,15 @@ For EACH word, produce one entry:
dictionary sense. dictionary sense.
- partOfSpeech: one of verb, noun, adjective, adverb, pronoun, preposition, - partOfSpeech: one of verb, noun, adjective, adverb, pronoun, preposition,
conjunction, article, interjection, numeral, proper noun, other. conjunction, article, interjection, numeral, proper noun, other.
- gender: ONLY for `partOfSpeech == "noun"`. "m" for masculine, "f" for
feminine, "m/f" for nouns that take either article (estudiante, artista).
OMIT the field entirely (or use null) for non-nouns and for cases where the
gender is genuinely unknowable from context. Don't guess for non-nouns.
Write the output file as JSON with this exact shape: Write the output file as JSON with this exact shape:
{{"jobId": "<the jobId from the input>", "entries": [ {{"jobId": "<the jobId from the input>", "entries": [
{{"word": "...", "baseForm": "...", "english": "...", "partOfSpeech": "..."}} {{"word": "...", "baseForm": "...", "english": "...",
"partOfSpeech": "...", "gender": "m"}}
]}} ]}}
`entries` MUST contain exactly one object per input word, cover every word, and `entries` MUST contain exactly one object per input word, cover every word, and
+5 -1
View File
@@ -109,11 +109,15 @@ def main() -> None:
word = (entry.get("word") or "").strip() word = (entry.get("word") or "").strip()
if not word: if not word:
continue continue
glossary[word] = { gloss_entry: dict = {
"baseForm": entry.get("baseForm") or word, "baseForm": entry.get("baseForm") or word,
"english": entry.get("english") or "", "english": entry.get("english") or "",
"partOfSpeech": entry.get("partOfSpeech") or "", "partOfSpeech": entry.get("partOfSpeech") or "",
} }
gender = entry.get("gender")
if isinstance(gender, str) and gender.strip():
gloss_entry["gender"] = gender.strip()
glossary[word] = gloss_entry
if glossary_missing: if glossary_missing:
msg = f"{len(glossary_missing)} glossary job(s) missing output: {glossary_missing[:5]}{'...' if len(glossary_missing) > 5 else ''}" msg = f"{len(glossary_missing)} glossary job(s) missing output: {glossary_missing[:5]}{'...' if len(glossary_missing) > 5 else ''}"
if args.require_all: if args.require_all:
+1
View File
@@ -0,0 +1 @@
.cache/
+62
View File
@@ -0,0 +1,62 @@
# Vocab catalog build
`build_lexemes.py` produces `Conjuga/vocab_lexemes.json`, the bundled catalog
of frequency-ranked Spanish nouns and adjectives that powers the Noun /
Adjective flashcard study modes.
## Run
```sh
python3 build_lexemes.py
```
Downloads `frequency.csv` + `es-en.data` from a pinned commit of
[`doozan/spanish_data`](https://github.com/doozan/spanish_data), caches them
under `.cache/<commit>/`, joins them, and writes the JSON. Re-running is
fast — only the join step happens after the first download.
Override defaults:
```sh
python3 build_lexemes.py --max-nouns 3000 --max-adjectives 1000
python3 build_lexemes.py --output /tmp/vocab.json
```
## Data sources & attribution
All datasets are CC-licensed; the bundled catalog inherits CC-BY-SA. Credit
in the app's About screen must read:
> Vocabulary data: Wiktionary (CC-BY-SA), OpenSubtitles via FrequencyWords
> (CC-BY-SA 3.0).
- **`frequency.csv`** — derived from
[hermitdave/FrequencyWords](https://github.com/hermitdave/FrequencyWords)
(OpenSubtitles corpus), packaged by doozan. License: CC-BY-SA 3.0.
- **`es-en.data`** — Spanish→English Wiktionary export in the
[`enwiktionary_wordlist`](https://github.com/doozan/enwiktionary_wordlist)
format. License: CC-BY-SA.
The pinned doozan commit is at the top of `build_lexemes.py`
(`DOOZAN_COMMIT`). Bump it to refresh; the cache key includes the commit so
old data is auto-replaced.
## Output shape
```json
[
{
"baseForm": "casa",
"english": "house",
"partOfSpeech": "noun",
"gender": "f",
"frequencyRank": 142,
"exampleES": "La casa es grande",
"exampleEN": "The house is big"
},
...
]
```
Sorted by `frequencyRank` ascending so the fresh-card path in `LexemePool`
surfaces the most useful words first.
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Build Conjuga/vocab_lexemes.json from doozan/spanish_data.
Joins doozan's frequency.csv (CC-BY-SA 3.0, OpenSubtitles via FrequencyWords)
with es-en.data (CC-BY-SA, Wiktionary) into a single bundled JSON catalog of
the highest-frequency Spanish nouns and adjectives — each row carries the
lemma, English gloss, gender (for nouns), frequency rank, and an example
sentence with translation when Wiktionary has one.
The app's DataLoader.seedLexemesFromCatalog reads this file at startup to
populate the Lexeme table that powers Noun / Adjective flashcard study.
Usage:
python3 build_lexemes.py [--max-nouns N] [--max-adjectives N]
[--output PATH] [--cache-dir PATH]
Pinned doozan commit: aeac698949e7b27112056ee8d72f70f853cd1ef9 (2026-05-01)
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
import urllib.request
from pathlib import Path
DOOZAN_COMMIT = "aeac698949e7b27112056ee8d72f70f853cd1ef9"
BASE_URL = f"https://raw.githubusercontent.com/doozan/spanish_data/{DOOZAN_COMMIT}"
FILES = {
"frequency.csv": f"{BASE_URL}/frequency.csv",
"es-en.data": f"{BASE_URL}/es-en.data",
}
# Both frequency.csv and es-en.data use short POS codes (`n`, `adj`); we keep
# the same codes for the join. The output JSON uses the longer names the
# app's Lexeme model expects.
JOIN_POS = {"n", "adj"}
OUTPUT_POS = {"n": "noun", "adj": "adjective"}
def fetch(name: str, url: str, cache_dir: Path) -> Path:
"""Download once; reuse local cache on subsequent runs."""
cache_dir.mkdir(parents=True, exist_ok=True)
out = cache_dir / name
if out.exists() and out.stat().st_size > 0:
return out
print(f" downloading {name} ({url}) ...", file=sys.stderr)
with urllib.request.urlopen(url) as resp, open(out, "wb") as fh:
fh.write(resp.read())
return out
def load_frequency(path: Path, *, keep_pos: set[str]) -> list[dict]:
"""Read frequency.csv → list of {lemma, pos, rank} for the POSes we care
about. Rank is the row index (1-based), which matches frequency-descending
order in the source file."""
rows: list[dict] = []
with open(path, encoding="utf-8") as fh:
reader = csv.DictReader(fh)
for i, row in enumerate(reader):
pos = (row.get("pos") or "").strip()
if pos not in keep_pos:
continue
flags = (row.get("flags") or "").strip()
if "DUPLICATE" in flags or "NOUSAGE" in flags:
continue
lemma = (row.get("spanish") or "").strip()
if not lemma:
continue
rows.append({"lemma": lemma, "pos": pos, "rank": i + 1})
return rows
def load_es_en(path: Path) -> dict[tuple[str, str], dict]:
"""Parse es-en.data → {(lemma, pos): {gender, english, exampleES, exampleEN}}.
A single `_____`-delimited block can hold multiple `pos:` sub-entries
for the same lemma (e.g. `rojo` is both an adjective ("red") and a
masculine noun ("a red one"); `mano` has two noun senses with different
genders). We commit each sub-entry when we see the next `pos:` line, so
`(lemma, pos)` pairs don't get clobbered by later same-block sub-entries.
First-sense-wins on duplicate keys, which aligns with Wiktionary listing
the most-common meaning first.
"""
entries: dict[tuple[str, str], dict] = {}
lemma = pos = gender = english = ex_es = ex_en = None
next_is_lemma = False
def commit_subentry() -> None:
nonlocal pos, gender, english, ex_es, ex_en
if lemma and pos and english:
key = (lemma, pos)
if key not in entries:
entries[key] = {
"gender": gender,
"english": english,
"exampleES": ex_es,
"exampleEN": ex_en,
}
pos = gender = english = ex_es = ex_en = None
def reset_entry() -> None:
nonlocal lemma
commit_subentry()
lemma = None
with open(path, encoding="utf-8") as fh:
for raw in fh:
line = raw.rstrip("\n")
stripped = line.lstrip()
if stripped == "_____":
reset_entry()
next_is_lemma = True
continue
if next_is_lemma:
lemma = stripped
next_is_lemma = False
continue
if stripped.startswith("pos: "):
# Starting a new sub-entry for the current lemma; commit the
# previous sub-entry's state before resetting.
commit_subentry()
pos = stripped[5:].strip()
elif stripped.startswith("g: "):
gender = stripped[3:].strip()
elif stripped.startswith("gloss: "):
if english is None:
english = stripped[7:].strip()
elif stripped.startswith("ex: "):
if ex_es is None:
ex_es = stripped[4:].strip()
elif stripped.startswith("eng: "):
if ex_en is None:
ex_en = stripped[5:].strip()
reset_entry()
return entries
def normalize_gender(g: str | None) -> str | None:
"""Reduce Wiktionary gender codes to {m, f, m/f, None}.
`mp` (masculine plural) / `fp` (feminine plural) are inherently-plural
nouns (gafas, pantalones); they don't fit the singular el/la drill cleanly
in v1, so we drop them here and the entry is filtered out upstream.
"""
if not g:
return None
g = g.strip()
if g in ("m", "f"):
return g
if g in ("mf", "m/f", "m, f", "f, m"):
return "m/f"
return None
def build(args) -> None:
cache = Path(args.cache_dir).expanduser()
paths = {name: fetch(name, url, cache) for name, url in FILES.items()}
print(
f"Reading frequency.csv (top {args.max_nouns} nouns, "
f"top {args.max_adjectives} adjectives) ...",
file=sys.stderr,
)
rows = load_frequency(paths["frequency.csv"], keep_pos=JOIN_POS)
nouns = [r for r in rows if r["pos"] == "n"][: args.max_nouns]
adjs = [r for r in rows if r["pos"] == "adj"][: args.max_adjectives]
print(f" candidates: {len(nouns)} nouns, {len(adjs)} adjectives", file=sys.stderr)
print("Parsing es-en.data ...", file=sys.stderr)
es_en = load_es_en(paths["es-en.data"])
print(f" {len(es_en)} (lemma, pos) entries", file=sys.stderr)
out: list[dict] = []
skipped_no_entry = 0
skipped_no_english = 0
skipped_no_gender = 0
for source_rows in (nouns, adjs):
for r in source_rows:
short_pos = r["pos"]
output_pos = OUTPUT_POS[short_pos]
entry = es_en.get((r["lemma"], short_pos))
if not entry:
skipped_no_entry += 1
continue
english = entry.get("english")
if not english:
skipped_no_english += 1
continue
gender = normalize_gender(entry.get("gender")) if short_pos == "n" else None
if short_pos == "n" and gender is None:
# Drill needs gender; if Wiktionary doesn't have it, skip.
skipped_no_gender += 1
continue
out.append({
"baseForm": r["lemma"],
"english": english,
"partOfSpeech": output_pos,
"gender": gender,
"frequencyRank": r["rank"],
"exampleES": entry.get("exampleES"),
"exampleEN": entry.get("exampleEN"),
})
out.sort(key=lambda e: e["frequencyRank"])
out_path = Path(args.output).expanduser()
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, separators=(",", ":"))
fh.write("\n")
noun_count = sum(1 for e in out if e["partOfSpeech"] == "noun")
adj_count = sum(1 for e in out if e["partOfSpeech"] == "adjective")
print(
f"Wrote {out_path}{noun_count} nouns, {adj_count} adjectives "
f"({len(out)} total, {out_path.stat().st_size:,} bytes)",
file=sys.stderr,
)
print(
f" skipped: no es-en entry={skipped_no_entry}, "
f"no english={skipped_no_english}, "
f"no gender={skipped_no_gender}",
file=sys.stderr,
)
def main() -> None:
here = Path(__file__).resolve().parent
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--max-nouns", type=int, default=1500)
parser.add_argument("--max-adjectives", type=int, default=600)
parser.add_argument(
"--output",
default=str(here / ".." / ".." / "Conjuga" / "vocab_lexemes.json"),
)
parser.add_argument(
"--cache-dir",
default=str(here / ".cache" / DOOZAN_COMMIT[:8]),
)
build(parser.parse_args())
if __name__ == "__main__":
main()
@@ -44,15 +44,24 @@ public final class Book {
} }
/// One glossary entry: a word's dictionary base form, English meaning, and /// One glossary entry: a word's dictionary base form, English meaning, and
/// part of speech, translated in the book's context at import time. /// part of speech, translated in the book's context at import time. `gender`
/// is populated by the glossary pipeline for nouns ("m"/"f"/"m/f"); nil for
/// non-nouns or when the pipeline hasn't been re-run yet.
public struct WordGloss: Codable, Hashable, Sendable { public struct WordGloss: Codable, Hashable, Sendable {
public let baseForm: String public let baseForm: String
public let english: String public let english: String
public let partOfSpeech: String public let partOfSpeech: String
public let gender: String?
public init(baseForm: String, english: String, partOfSpeech: String) { public init(
baseForm: String,
english: String,
partOfSpeech: String,
gender: String? = nil
) {
self.baseForm = baseForm self.baseForm = baseForm
self.english = english self.english = english
self.partOfSpeech = partOfSpeech self.partOfSpeech = partOfSpeech
self.gender = gender
} }
} }
@@ -0,0 +1,91 @@
import Foundation
import SwiftData
/// A non-verb vocabulary item harvested from the books pipeline's per-book
/// glossary. Verbs keep their own richer `Verb` model `Lexeme` covers
/// nouns, adjectives, etc. so the flashcard study modes can drill the grammar
/// that's specific to each part of speech.
///
/// Identity is `"<sourceBookSlug>:<partOfSpeech>:<baseForm>"`; the seeder
/// dedupes on `(partOfSpeech, baseForm)` across books and keeps the first-
/// seen source. Lives in the LOCAL reference-data store (same place as
/// `Book`/`BookChapter`), not the cloud container.
@Model
public final class Lexeme {
@Attribute(.unique) public var id: String = ""
public var partOfSpeech: String = ""
public var baseForm: String = ""
public var english: String = ""
/// For nouns: "m", "f", or "m/f". Nil for non-nouns or when unknown.
/// The curated catalog (`vocab_lexemes.json` from doozan/spanish_data)
/// emits Wiktionary-sourced gender; `Lexeme.inferGender` provides a
/// morphology fallback if a different seeder ever lands a noun without
/// one.
public var gender: String? = nil
/// Source tag `"catalog"` for entries from `vocab_lexemes.json`, or a
/// book slug for legacy book-glossary-derived entries. Used to keep
/// catalog refreshes from wiping book-personal additions later.
public var sourceBookSlug: String = ""
/// 1-based rank in the source frequency list (lower = more common).
/// 0 means unknown/unranked. `LexemePool` sorts fresh cards by this so
/// the most-useful words surface first.
public var frequencyRank: Int = 0
/// Optional example sentence pair, shown below the answer in Recall
/// mode. Sourced from Wiktionary's `ex:`/`eng:` lines when available.
public var exampleES: String? = nil
public var exampleEN: String? = nil
public init(
id: String,
partOfSpeech: String,
baseForm: String,
english: String,
gender: String? = nil,
sourceBookSlug: String = "",
frequencyRank: Int = 0,
exampleES: String? = nil,
exampleEN: String? = nil
) {
self.id = id
self.partOfSpeech = partOfSpeech
self.baseForm = baseForm
self.english = english
self.gender = gender
self.sourceBookSlug = sourceBookSlug
self.frequencyRank = frequencyRank
self.exampleES = exampleES
self.exampleEN = exampleEN
}
public static func makeID(sourceBookSlug: String, partOfSpeech: String, baseForm: String) -> String {
"\(sourceBookSlug):\(partOfSpeech):\(baseForm)"
}
/// Best-effort gender from Spanish morphology. Used as a fallback when
/// the glossary pipeline hasn't emitted a `gender` field yet. Conservative:
/// returns nil for ambiguous endings rather than guessing wrong.
///
/// - `-ción/-sión/-dad/-tad/-tud/-umbre/-ez/-anza` feminine
/// - `-aje/-or` masculine
/// - `-ma/-pa/-ta` nil (Greek-origin masculines mix with regular -a feminines)
/// - `-a` (other) feminine
/// - `-o` masculine
/// - everything else nil
public static func inferGender(forBaseForm baseForm: String) -> String? {
let s = baseForm.lowercased()
if s.hasSuffix("ción") || s.hasSuffix("sión") || s.hasSuffix("dad") ||
s.hasSuffix("tad") || s.hasSuffix("tud") || s.hasSuffix("umbre") ||
s.hasSuffix("ez") || s.hasSuffix("anza") {
return "f"
}
if s.hasSuffix("aje") || s.hasSuffix("or") {
return "m"
}
if s.hasSuffix("ma") || s.hasSuffix("pa") || s.hasSuffix("ta") {
return nil
}
if s.hasSuffix("a") { return "f" }
if s.hasSuffix("o") { return "m" }
return nil
}
}
@@ -0,0 +1,47 @@
import Foundation
/// CEFR-style level for a `Lexeme`, derived from its `frequencyRank`. Lets
/// users gate noun/adjective flashcard sessions by level via a Settings
/// toggle. Cutoffs follow the standard Spanish-frequency-dictionary
/// convention (Davies; RAE CEFR-aligned lists).
///
/// Note: SRS is *not* level-gated. Disabling a level only stops *new*
/// cards from that band entering the session pool already-studied cards
/// keep coming back on their SM-2 schedule regardless. See
/// `LexemePool.sessionLexemes` for where the filter is applied.
public enum LexemeLevel: String, Codable, Hashable, CaseIterable, Sendable {
case a1, a2, b1, b2, c1
/// 1-based frequency rank range. `c1` is open-ended on the high end so
/// any far-tail entry has a level even if the catalog later expands.
public var rankRange: ClosedRange<Int> {
switch self {
case .a1: return 1...250
case .a2: return 251...500
case .b1: return 501...1000
case .b2: return 1001...2000
case .c1: return 2001...Int.max
}
}
public var displayName: String {
switch self {
case .a1: return "A1 — Beginner"
case .a2: return "A2 — Elementary"
case .b1: return "B1 — Intermediate"
case .b2: return "B2 — Upper-intermediate"
case .c1: return "C1+ — Advanced"
}
}
/// The level containing this frequency rank. Rank 0 (unranked) falls
/// into `c1` better to include unknown-rank lexemes when only the
/// top end is on than silently drop them.
public static func level(forRank rank: Int) -> LexemeLevel {
guard rank > 0 else { return .c1 }
for level in LexemeLevel.allCases where level.rankRange.contains(rank) {
return level
}
return .c1
}
}
@@ -39,6 +39,7 @@ public enum SharedStore {
TenseGuide.self, CourseDeck.self, VocabCard.self, TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self, DownloadedVideo.self, TextbookChapter.self, DownloadedVideo.self,
Book.self, BookChapter.self, Book.self, BookChapter.self,
Lexeme.self,
] ]
} }
} }
+6
View File
@@ -58,6 +58,12 @@ targets:
buildPhase: resources buildPhase: resources
- path: Conjuga/textbook_vocab.json - path: Conjuga/textbook_vocab.json
buildPhase: resources buildPhase: resources
- path: Conjuga/book_olly-vol2.json
buildPhase: resources
- path: Conjuga/vocab_lexemes.json
buildPhase: resources
- path: Conjuga/CourseMaterials
buildPhase: resources
info: info:
path: Conjuga/Info.plist path: Conjuga/Info.plist
properties: properties: