65 Commits

Author SHA1 Message Date
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
Trey T ac84b22977 Books — fix infinite render loop via value-based navigation
Opening a book chapter froze the app in an infinite render loop. Root
cause: the books screens used the eager `NavigationLink { destination }`
form inside `List`/`LazyVStack`. That form keeps the destination view
structurally parented to the source row, so `BookReaderView`'s ScrollView
got trapped inside a `List` row — a row sizes to intrinsic height, a
ScrollView has none, so the two never converge and re-measure forever.

Switch the whole books navigation chain to value-based navigation:
- practiceHomeView, BookLibraryView, BookChapterListView use
  NavigationLink(value:).
- PracticeView's NavigationStack declares the BooksRoute, Book, and
  BookChapter destinations once, at the stack root (mixing eager and
  value-based pushes in one path caused pushed screens to pop back).
- BookReaderView is built from just a BookChapter; it resolves its Book
  by slug via @Query.

Also:
- BookChapter gains a stored paragraphCount so the chapter list no longer
  decodes the full paragraph JSON on every render (bookDataVersion -> 6
  to re-seed).
- BookSpeechController builds its AVSpeechSynthesizer lazily.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:08:36 -05:00
Trey T 3ee1563cb0 Books — pre-computed per-book glossary for context-correct word lookup
The book reader's word lookup used DictionaryService, a verb-conjugation
index plus ~200 hand-typed words: ordinary nouns like "taza" returned
nothing, and homographs always lost (tapping "como" in "como siempre"
gave the verb "comer" because the verb index is checked first).

Add a glossary phase to the books pipeline (build_glossary.py): every
distinct Spanish word is translated once, in its sentence context, by
the same Claude-Code-subagent LLM step the pipeline already uses for
chapter translation. English front matter is excluded by an ES==EN
paragraph-ratio heuristic. The glossary is bundled into book_<slug>.json
and is now part of the pipeline for every book.

In the app, Book carries the decoded glossary and BookReaderView resolves
each tap automatically through cache -> glossary -> DictionaryService ->
on-device LLM, citing which source answered so a curated glossary hit
reads differently from a best-effort AI guess.

book_olly-vol2.json regenerated with a 3,658-word glossary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:44:32 -05:00
Trey T d0582c4ce7 Full Table — vary tense and verb family between consecutive prompts
randomFullTablePrompt now takes the previous prompt's tense and verb
ending and picks the next one to avoid an immediate repeat: when more
than one tense is selected the just-shown tense is excluded, and when
both -ar and -er/-ir verbs are available the next verb switches family.

Both constraints are best-effort — if honouring one would leave no
eligible fully-regular combo it is dropped, and the exhaustive
"anything eligible at all" guarantee is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:41:18 -05:00
Trey T 26ce662c60 Fix widget wiping the Books tables on every refresh
The widget extension opened the shared local SwiftData store with a
7-entity schema while the app's store has 10. SwiftData treats the
smaller schema as a migration and destructively drops the unlisted
tables — so every widget refresh deleted the bundled Book/BookChapter
rows (and DownloadedVideo), which is why books vanished after reinstalls.

Introduce SharedStore.localSchemaModels as the single source of truth
for the local schema and build the app and both widget containers from
it, so app and widget can no longer drift apart. The same class of bug
hit TextbookChapter previously; a shared list prevents a third recurrence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:12:51 -05:00
Trey T dd08a09860 Vocab Practice — apply code-review fixes for the SRS session work
Addresses the lead-review findings on the new vocab SRS/persistence code:

- Resume a persisted study group only when every stored verb resolves,
  else rebuild fresh — a partial resume desynced learnedCount from a
  shrunken queue and then persisted the loss.
- studyAgain() clears the finished group before building a new one.
- VocabStudyGroupStore.persist() collapses duplicate group records
  (cross-device sync races) down to the newest.
- Learn mode now derives its verb list from the live quiz queue, so it
  browses exactly what's left to learn instead of a stale frozen pool.
- VerbExampleGenerator retries when the first batch throws, not only
  when it returns bad data.
- Per-verb example generation tracks in-flight verbs in a Set, so the
  loading spinner clears reliably and duplicate generations are skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:59:33 -05:00
Trey T c794c013f0 Vocab Flashcards — persist the study group across launches and devices
The session queue was in-memory only: leaving the flashcard screen or
killing the app rebuilt it from scratch. The set of verbs you were
working through wasn't sticky.

VocabStudyGroup (new cloud-synced @Model) — the active standard study
set: the in-session queue (un-graduated verbs + each card's
learning-step state) as a JSON blob, plus the learned count. One active
group at a time, keyed "active-standard". CloudKit-synced, so the same
group follows you from iPad to iPhone.

VocabStudyGroupStore — fetch-or-create / persist / clear. activeGroup()
returns the newest if duplicates exist (two devices racing before sync
settles); clear() removes all duplicates.

VocabSessionQueue — CardState is now String-backed; added
init(entries:learnedCount:) to resume a saved queue and snapshot() to
export it.

VocabFlashcardPracticeView (standard kind):
  - On load, resume the active group if one exists; otherwise build a
    fresh set and persist it immediately.
  - Every answer writes the updated queue to the group.
  - Finishing the set clears the group; the completion button is now
    "Next Set" and builds a fresh group.
Review Learned (reviewLearned kind) is unaffected — it's an ad-hoc cram
and intentionally isn't persisted; its button stays "Study Again".

Registered VocabStudyGroup in the cloud container schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:42:17 -05:00
Trey T eec0fb56d5 Vocab Practice — add "Review Learned" consolidation mode
After a few Vocab Flashcards sessions, the verbs you've learned have
future SM-2 due dates, so they're excluded from new sessions (which
only pull due + new). There was no way to deliberately re-review a set
you'd already memorised.

Review Learned (new row in Practice → Vocabulary) fixes that — a cram
pass over every verb that has a VerbReviewCard, most-recently-studied
first, uncapped.

  VocabVerbPool.reviewLearnedVerbs — studied verbs sorted by
  lastReviewDate desc. Ignores due dates and the Level filter; it's a
  deliberate "review everything I've learned" pass.

  VocabFlashcardPracticeView gains a `kind` parameter
  (.standard / .reviewLearned). reviewLearned uses the new pool and —
  crucially — does NOT call VerbReviewStore.rate on graduation. Ratings
  drive the in-session learning queue for the session feel, but the
  long-term schedule is left untouched (Anki's reschedule-off cram
  behaviour), so a consolidation pass can't shove your real due dates
  weeks out.

  Header shows "Practice pass — your review schedule won't change" in
  this mode. Quiz/Learn toggle and Study Again work the same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:24:47 -05:00
Trey T 209602eaad Docs — refresh feature reference + app_features for this session's work
Both the in-app Feature Reference and app_features.md were last updated
2026-04-21, before Books, the verb-table vocab SRS, Extra Study,
read-along, the guide enrichment, and the Practice-tab restructure.

FeatureReferenceView (Settings → How Features Work):
  - Regrouped to match the live Practice tab: Conjugation / Vocabulary
    / Reading sections.
  - Added Vocab Flashcards (Quiz + Learn modes, in-session learning
    queue), Vocab Multiple Choice, Books + read-aloud, Extra Study,
    and Guide cross-links.
  - Tense/grammar counts corrected (36 grammar notes, enriched guides).
  - "Settings That Affect Practice" now lists Cards per session and
    notes the Verbs-tab ↔ Level sync.

app_features.md (Conjuga section of the comparison doc):
  - Practice modes split into Conjugation / Vocabulary / Reading.
  - Documented the two-layer vocab SRS, Books + books pipeline,
    Extra Study, guide enrichment + cross-links, configurable session
    size, and the VerbReviewCard cloud model.
  - Data table updated (36 grammar notes, bundled books row).

Docs only — no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:57:25 -05:00
Trey T cee962c0e0 Vocab Flashcards — make session size configurable in Settings
VocabVerbPool.sessionCardLimit was hardcoded at 20. Settings now has a
"Vocab Flashcards → Cards per session" picker (10 / 15 / 20 / 25 / 30 /
50 / All) backed by the vocabSessionCardLimit @AppStorage key.

VocabVerbPool.sessionCardLimit became a computed property reading that
key (0/unset → default 20; 999 → "All"). Applies to both Quiz and Learn
modes since they share the same session pool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:06:07 -05:00
Trey T f14008f96f Vocab Flashcards — add no-pressure Learn mode alongside the SRS quiz
A Quiz/Learn toggle now sits in the flashcard toolbar (Menu, persisted
via @AppStorage):

  Quiz — unchanged. The VocabSessionQueue SRS path: tap to reveal, rate
  Again/Hard/Good/Easy, requeue, graduation feeds VerbReviewStore.

  Learn — browsing, not testing. Both sides show at once (English +
  Spanish + example sentence + speaker button), Next/Previous step
  through the session pool, looping (wraps at both ends). No rating
  buttons, no SRS writes, no requeue — just repeated exposure.

Both modes draw from the same fetched session pool (due-first + new,
capped 20) and the same lazily-generated example cache. Switching
modes is instant — the quiz queue keeps its state, the learn cursor
keeps its position; they're independent. The header shows the SRS
progress in Quiz and a plain "3 of 20" position in Learn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:53:22 -05:00
Trey T 5c0fc8ee2d Vocab Practice — proper SRS session queue with in-session learning steps
Vocab Flashcards / Multiple Choice were a flat linear walk through a
shuffled list — rating a card "Hard" or "Again" did nothing to bring it
back. Replaced with a real two-layer SRS, matching how Anki separates
in-session learning steps from the long-term schedule.

VocabSessionQueue (new) — the in-session layer. Position-based learning
steps:
  - Again → card reappears 5–8 cards later (state: learning)
  - Hard  → reappears 7–10 cards later (state: learning)
  - Good  → first time: → review state, reappears 16–24 cards later
            already in review: graduates, leaves the session
  - Easy  → graduates immediately
A card you keep failing keeps cycling until you mark it Good twice or
Easy once. answer() returns a ReviewQuality only on graduation — that's
the single rating handed to the long-term VerbReviewStore, so
intermediate Again/Hard presses no longer thrash the cross-session
SM-2 schedule.

VocabVerbPool.sessionVerbs (rewritten) — due-first ordering + a 20-card
session cap. Overdue verbs (per VerbReviewCard.dueDate) come first,
most-overdue leading; then never-reviewed verbs by frequency rank.
Not-yet-due verbs are intentionally skipped — that's the SRS schedule
doing its job. A single sitting is now bounded instead of a 100+ card
slog.

Study Again — the completion screen gets a "Study Again" button that
rebuilds the queue from the same verb set (re-shuffled), so you can run
the whole set again after finishing.

Progress display switched from "1 of 110" to "N learned · M to go",
which reflects the live queue as cards requeue and graduate.

Both vocab views now share VocabSessionQueue + VocabVerbPool; the queue
struct is pure value-type logic, easy to reason about and test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:44:43 -05:00
Trey T d61f9e50b1 Vocab Practice — speaker button to hear the Spanish word
Both vocab session reveal panes now show a speaker button next to the
Spanish infinitive. Tapping it speaks the word via the existing
SpeechService (Spanish voice), same TTS the Course flashcards use.

Flashcard mode: button beside the infinitive in the reveal pane.
Multiple choice: button beside the infinitive in the answer feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:15:12 -05:00
Trey T 900a927f95 Fixes #34 — drop AI-generated images from verb vocab practice
Image Playground's .illustration style can't depict abstract verbs —
"dar" (to give) generated a Shiba Inu in a meadow, "saber" a cake.
Verbs are actions, not objects; a static illustration rarely helps and
the irrelevant output was pure noise.

Removed:
  - VerbIllustration view (was in VocabFlashcardPracticeView).
  - VocabImageService.swift entirely — VerbIllustration was its only
    consumer.
  - vocabImageService from the app environment in ConjugaApp.
  - The illustration row from both vocab session reveal panes.

Vocab card reveal is now: Spanish infinitive + example sentence +
rating buttons. The example sentence already carries the learning
context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:38:01 -05:00
Trey T f0eb75a28a Fixes #33 — verb examples must actually use the verb
The example sentences in VerbDetailView (and the new vocab practice
modes) frequently used the wrong verb — a "tener" set would show
"Él estaba leyendo un libro" (estar), "Nosotros vamos a viajar" (ir),
"Tú debes estudiar" (deber). The model drifted off the target verb
partway through generating the 6-example batch, and nothing checked
the output.

Two defenses:

  Prompt grounding — VerbExampleGenerator.generate now takes a
  formsByTense map (tenseId → conjugated forms, from the new
  ReferenceStore.conjugatedForms). Each tense line in the prompt
  lists the verb's exact conjugated forms and instructs the model to
  use one of them. The model echoes a real form instead of recalling
  (and mis-recalling) the conjugation.

  Output validation — every generated sentence is checked against the
  conjugation table via accent/case-folded whole-word matching. Any
  sentence that doesn't contain a real conjugated form of the verb is
  rejected. Failures trigger one regeneration pass; anything still
  wrong is dropped rather than displayed. Better to show 4 correct
  examples than 6 with 2 wrong.

  Cache invalidation — VerbExampleCache now persists a versioned
  wrapper (version 2). Pre-fix cached example sets — which may contain
  wrong-verb sentences — fail the version check and are discarded, so
  they regenerate cleanly under the new path.

Callers updated: VerbDetailView, VocabFlashcardPracticeView,
VocabMultipleChoicePracticeView all build formsByTense from
ReferenceStore and pass it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:57:57 -05:00
Trey T f4c139aed0 Fixes #35 — vocab practice no longer doubles the verb's "to"
VocabFlashcardPracticeView and VocabMultipleChoicePracticeView rendered
the English prompt as "to \(verb.english)". Every verb in conjuga_data
already stores english with a leading "to " (all 1750 of them), so the
prompt came out as "to to be", "to to run", etc.

Dropped the prefix — verb.english is shown verbatim. Same fix applied
to the Image Playground concept string so generated illustrations are
prompted with "to be" rather than "to to be".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:43:34 -05:00
Trey T 0af8e648fe Vocab Practice crash — defensive Dictionary init + correct tense count
Crash: Swift/NativeDictionary.swift:792 Fatal error: Duplicate values
for key: 'imp_tú', triggered the first time a user rated a vocab card
and the in-flight example generation tried to materialise.

Root cause: VerbExampleGenerator.generate() builds a [tenseId: example]
dictionary from the model's output via Dictionary(uniqueKeysWithValues:),
which traps on duplicates. The generator's @Generable schema declares
@Guide(.count(6)) on the examples array, so the LLM is forced to return
exactly 6. The new Vocab Flashcards / Multiple Choice views called
generate(... tenseIds: ["ind_presente"]) — only one tense — which left
the model to invent the other 5 tenseIds; it duplicated 'imp_tú' and
the dictionary init trapped.

Three fixes:

  Services/VerbExampleGenerator.swift — use Dictionary(_:uniquingKeysWith:)
  with first-wins so the generator can never crash regardless of caller
  shape.

  Views/Practice/Vocab/VocabFlashcardPracticeView.swift and
  Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift — pass
  the canonical 6-tense set (VocabExampleTenseIds.canonical, same as
  VerbDetailView uses), then pick the ind_presente example for the
  card. Caches all six in VerbExampleCache as a side effect.

  Views/Verbs/VerbListView.swift — replace empty-string systemImage
  on the Level menu Labels with "circle" so the device console isn't
  spammed with "No symbol named '' found in system symbol set" every
  time the user opens the filter menu.

The crash analysis I gave earlier (CloudKit schema migration of
VerbReviewCard) was wrong — device console shows the real culprit. No
CloudKit-side changes needed; the new model stays in the cloud
container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:02:34 -05:00
Trey T c890095610 Vocab Practice — verb pool, Settings level filter, per-verb SRS
Vocab Practice used a deck picker over VocabCard rows. That meant it
ignored the Settings level toggles entirely and operated on a totally
separate vocabulary universe than the conjugation modes. Rewired
end-to-end:

Pool source
  Replaced VocabCard with the Verb table. The pool is now
  ReferenceStore.fetchVerbs(selectedLevels: UserProgress.selectedVerbLevels)
  — the same call PracticeSessionService uses for conjugation. Changes
  to level toggles in Settings (or the Verbs tab, which also writes to
  this field) immediately affect Vocab Practice.

Entry flow
  Deleted VocabPracticeEntryView. Practice → Vocabulary now has two
  direct entries:
    • Vocab Flashcards — verb.english → tap → verb.infinitive
    • Vocab Multiple Choice — verb.english → pick from 4 infinitives
  Both pull from the same level-filtered pool, shuffled.

Per-verb SRS
  New VerbReviewCard @Model (cloud-synced, mirrors CourseReviewCard's
  SM-2 fields but keyed by verbId). VerbReviewStore.rate(verbId:quality:)
  applies the existing SRSEngine. Registered in cloud container schema
  in ConjugaApp.swift.

Example sentences
  Lazy-generated via VerbExampleGenerator on first reveal, cached
  through VerbExampleCache (same path VerbDetailView uses). Empty until
  the example arrives — block hides itself if Apple Intelligence isn't
  available.

AI illustration
  VerbIllustration replaces VocabIllustration; same Image Playground
  pipeline. Cache key uses ("verb", infinitive, english) so verbs and
  course-deck vocab never collide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:31:12 -05:00
Trey T 164a0a1bb7 Verbs — share level filter with Practice via UserProgress
VerbListView used a local @State selectedLevel that reset whenever
the user left the tab and was disconnected from Practice's verb
sourcing. PracticeSessionService reads UserProgress.selectedVerbLevels;
the Verbs tab now does the same.

  Level menu becomes multi-select (toggle each level in/out, mirrors
  SettingsView's pattern). "All Levels" enables every level; tapping
  again on a level removes it from the active set. Picking "All"
  while everything is already on is a no-op.

  Filter logic loops over the active level set and admits a verb if
  any active level matches via VerbLevelGroup.matches.

  Active-filter chip shows "Basic", "Elementary", etc. when one level
  is on, or "N levels" when multiple are on. Tapping the chip resets
  to all-levels.

  Empty set is treated as "no filter applied" on the Verbs list, but
  setAllLevels(enabled: false) falls back to [.basic] rather than an
  empty set — Practice treats empty as "no verbs", so we guard against
  leaving the user with an empty pool.

Practice already reads UserProgress.selectedVerbLevels, so changes
made in the Verbs tab now flow into Practice automatically. No
Practice-side code changes needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:18:23 -05:00
Trey T d49eb38a6d Books — bump bookDataVersion to 4 to force re-seed
A device install was showing the Books row but no books inside. The
seed code is intact (bundle resource present, schema registered,
explicit-slug fallback in place). Bumping the version flips
versionCurrent to false on every existing install, forcing
refreshBooksDataIfNeeded to wipe + re-seed on next launch. The
diagnostic prints added earlier ([DataLoader] bundledBookJSONURLs
found N files / Book seeding complete) will tell us if it still
fails this time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:09:00 -05:00
Trey T 0b7d4a73ad Add Vocab Practice — English-first flashcards + multiple choice, with AI illustrations
Practice tab restructured into three sections:
- Conjugation: the 6 conjugation modes + the Common Tenses / Weak
  Verbs / Irregularity Drills focus buttons.
- Vocabulary: new "Vocab Practice" entry (flashcard or multiple
  choice, deck-picker on entry) + the existing Vocab Review.
- Reading: Stories, Books, Lyrics, Conversation, Listening, Cloze
  (moved here from the flat list).

VocabPracticeEntryView lets the user pick any course/textbook deck
(or "All decks") and a mode (Flashcard / Multiple Choice). Last-used
choice is remembered via @AppStorage.

VocabFlashcardPracticeView:
  Front shows the English meaning. Tap to reveal the Spanish word,
  example sentences from the card, an AI-generated illustration of
  the concept, and Again/Hard/Good/Easy rating buttons. SRS updates
  via the existing CourseReviewStore.rate() path.

VocabMultipleChoicePracticeView:
  English prompt, 4 Spanish options. Distractors come from the same
  deck and prefer matching part-of-speech (via
  DictionaryService.lookup) — falls back to random when POS is
  unknown or the deck has fewer than 4 cards. After answer: reveal
  correct/incorrect, the Spanish word, examples, illustration, and
  the same rating buttons.

VocabImageService wraps Apple Intelligence's ImageCreator
(iOS 18.2+) for on-device illustration generation. Caches PNG
results to disk under Caches/VocabImages keyed by
SHA256(deck+ES+EN). In-flight dedup keeps concurrent requests for
the same key sharing one task. Falls back to a placeholder UI when
Apple Intelligence isn't available (older devices / disabled in
Settings) — detected lazily on the first failed ImageCreator init.

EN-first direction is enforced regardless of the underlying deck's
isReversed flag, so the user sees the English-to-Spanish recall
direction they asked for even when practising a reversed course
deck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:02:02 -05:00
Trey T 9aa4d0836d Guide — bidirectional cross-link chips between tense guides & grammar notes
After the full enrichment pass, both the Tenses and Grammar surfaces of
the Guide tab cover overlapping material — WEIRDO appears in both
"Subjuntivo Presente" and "Subjunctive Triggers", preterite↔imperfect
contrast in three places, etc. Instead of trimming either body and
losing content, add a small chip row at the top of each detail view
linking directly across.

GuideCrossLinks.swift (new) — curated tense→[noteId] map covering 18 of
the 20 tenses. The two without aligned notes (ind_pluscuamperfecto,
ind_preterito_anterior) don't show chips. The reverse map (noteId→
[tenseId]) is derived once at static init and sorted by canonical tense
order so chips appear in conjugation-table order.

GuideDetailView — "Related grammar" indigo chip row directly under the
header. Tap a chip → switch to the Grammar segment with that note
selected.

GrammarNoteDetailView — "Used in tenses" orange chip row directly under
the title. Tap a chip → switch to the Tenses segment with that tense
selected.

The GuideView segment-change handler now only clears the *other* tab's
selection so programmatic jumps keep their destination intact; manual
segment swipes still feel "fresh" like before.

No content is removed. Users get a deeper-dive path one tap away in
either direction, and the redundancy becomes a feature instead of a
maintenance hazard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:59:46 -05:00
Trey T 5db4b014a9 Guide — full enrichment pass: 19 tense guides + 36 grammar notes
Brings every tense guide and grammar note in the app up to teacher-
handout depth via parallel research subagents drafting against a
shared "thorough" checklist (TL;DR, usages, conjugation table, common
irregulars, mnemonic, top pitfalls, contrast with neighbour topic,
real-world dialogue example).

Tense guides — Conjuga/conjuga_data.json (tenseGuides[].body)
  All 19 remaining guides rewritten. The 20th (subj_presente) was
  enriched in the prior commit. Each body now ~4-5.5K chars (vs the
  500-1500 chars of the pre-pass reference cards), covering:
  - All five indicative tenses, both conditionals, both imperatives.
  - Full subjunctive set including the archaic futuro / futuro
    perfecto, framed honestly with "recognise, don't produce" guidance.
  - Per-tense conjugation patterns and the top 5-15 irregular verbs.
  - Tense-vs-tense contrasts (preterite↔imperfect, future↔ir-a,
    -ra↔-se past subjunctive, etc.).
  - Pitfalls that English speakers actually make.

Grammar notes — Conjuga/Conjuga/Models/GrammarNote.swift
  All 36 notes audited and rewritten where the existing body was
  missing one of: explicit mnemonic, contrast pair, pitfalls section,
  or coverage of a key sub-topic. None copied verbatim — every note
  got at least one of those slotted in. Notable additions:
  - DOCTOR/PLACE, WEIRDO, ESCAPA, RID, PRODDS, BANGS, RRPIA mnemonics
    where missing.
  - commands-imperative: nosotros + vosotros forms were entirely
    absent; both added with the -d/-os and present-subjunctive rules.
  - relative-pronouns: el que/el cual distinction, cuyo, lo que/lo
    cual, donde/adonde.
  - se-constructions: all 6 uses including the le→se substitution.
  - irregular-yo-verbs: impact on subjunctive and negative tú command.
  - Plus 5-item pitfalls sections on every note that lacked one.

Tooling — Conjuga/Scripts/guide-enrichment/
  - PLAN.md (prior commit) — the audit, checklist, and priority order
    that drove this pass.
  - apply_drafts.py (new) — reads drafts/out/*.md, swaps tense guides
    into the JSON and grammar notes into the Swift source via regex on
    the GrammarNote(...) declarations. Handles multi-block `#` comment
    headers some agents emitted. drafts/in/ and drafts/out/ are
    gitignored — regeneratable from current state.

DataLoader.swift — courseDataVersion 8 → 9 so existing installs re-
seed all guides on next launch.

Verification:
  - `swift -frontend -parse` on GrammarNote.swift succeeds (exit 0).
  - JSON validates (python3 json.load round-trip).
  - Triple-quote count is even (72 = 36 pairs, matching 36 notes).
  - Full xcodebuild verify deferred — local SDK install was disrupted
    by an Xcode update; will retest as part of the next ad-hoc deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:50:35 -05:00
Trey T de446b2301 Guide — enrich present-subjunctive entry with WEIRDO + ESCAPA + plan
The present-subjunctive guide was surface-level: two numbered usages
and a handful of examples, no mnemonic and no structural trigger cue.
That's the recurring problem with the tense guides — they're reference
cards, not teaching materials.

This commit fixes the immediate gap and lays out a plan to fix the
rest:

  Conjuga/conjuga_data.json — subj_presente body expanded from 794 to
  3670 chars. Adds the WEIRDO mnemonic with per-letter triggers and
  examples (Wishes, Emotions, Impersonal, Recommendations, Doubt,
  Ojalá), the ESCAPA adverbial-conjunction set, the "que + change of
  subject" structural rule, adjectival clauses with unknown
  antecedents, and the future-time-clause rule (cuando / hasta que /
  en cuanto).

  Scripts/guide-enrichment/PLAN.md (new) — audit of all 20 tense
  guides and 36 grammar notes, tier-1/2/3 prioritisation, "thorough"
  checklist (TL;DR, usages, conjugation, irregulars, mnemonic,
  pitfalls, contrast, dialogue example), research sources, per-topic
  workflow, effort estimate.

  DataLoader.swift — courseDataVersion 7 → 8 so existing installs
  re-seed the new body on next launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:33:09 -05:00
Trey T a416233a2d Books — read-aloud mode with active-word highlight and tap-to-define
Adds a TTS read-along to the book reader. Tap the play button in the
toolbar; AVSpeechSynthesizer reads the chapter paragraph-by-paragraph
with the current word highlighted in yellow, auto-scrolling the active
paragraph to centre. Tap any word during read-along to pause and open
the definition sheet; reading resumes when the sheet dismisses.

Behavior per spec:
- Tap-to-define interrupts the synth (pauseSpeaking at: .immediate) and
  resumes on sheet dismiss.
- Voice picker sheet (waveform.circle toolbar button) lists installed
  Spanish voices grouped by Premium / Enhanced / Default quality, with
  a "Download more voices…" row that opens iOS Settings (no public
  deep-link to Accessibility → Spoken Content exists; the footer spells
  out the path).
- Speed picker (Slow / Normal / Fast) drives AVSpeechUtterance.rate.
- Stops at chapter end, no auto-advance to the next chapter.
- Vocabulary lines shaped `palabra = meaning` are skipped — the synth
  would otherwise say "palabra equals meaning" and they're reference
  material, not prose.

Audio session uses .playback + .spokenAudio mode and is properly
deactivated with .notifyOthersOnDeactivation on stop() so music apps
resume cleanly after reading ends.

Voice/rate persisted via @AppStorage; controller picks them up
onAppear and writes back through Bindings the picker mutates.

Word-index space in BookSpeechController.wordRanges(in:) matches
BookReaderView's split(separator: " ") rendering exactly — both split
on ASCII U+0020 only, so willSpeakRange callbacks resolve to the right
visible word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:42:09 -05:00
Trey T 70d8299df8 Books — robust bundle lookup so ad-hoc device installs always seed
bundledBookJSONURLs() was relying solely on
Bundle.urls(forResourcesWithExtension:subdirectory:), the directory-
enumeration API. That API is observed to return empty in some on-device
configurations even when the resource is present, which would cause
seedBooks() to silently no-op and leave BookLibraryView showing "No
Books" despite the JSON being bundled correctly.

Switched to the same pattern textbook seeding uses: explicit per-slug
Bundle.url(forResource:withExtension:) with a bundleURL.appending-
PathComponent fallback. Directory enumeration is kept as a secondary so
future books bundled without code changes still get picked up.

Also added diagnostic prints to refreshBooksDataIfNeeded and the URL
discovery step so device logs reveal what happened if seeding still
falls through.

Bumped bookDataVersion to 3 so existing installs re-trigger
refreshBooksDataIfNeeded → seedBooks on next launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:36:16 -05:00
Trey T 51067e23fd Fix Books navigation — tapping a book no longer re-pushes the library
BookLibraryView is itself pushed from PracticeView's NavigationStack,
so the .navigationDestination(for: Book.self) it declared was a
non-root registration. Combined with NavigationLink(value: book), that
resolved the push to *both* the destination handler and the closure
that produced BookLibraryView originally — pushing the chapter list
underneath, then re-pushing the library on top. Hitting back popped
the library and revealed the chapter list, in the wrong order.

Switched both Library→ChapterList and ChapterList→Reader to closure-
based NavigationLinks. Destinations attach directly to the link, no
type-keyed registry involved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:18:04 -05:00
Trey T 05a367fdbe Books — capture <li> vocab bullets the extractor was silently dropping
extract_epub.py was walking <p> only, but every "Vocabulario" section in
the Olly Richards EPUB lives inside <ul><li>...</li></ul>. That meant
the heading made it through but the entries didn't — 680 vocab lines
across 24 sections in this book were missing from the bundled JSON.

Audit (text-node owner by closest block ancestor) confirmed <li> is the
only silent drop: 5,260 nodes in <p>, 1,960 in <li>, 0 anywhere else.
No <h1>-<h6>, tables, or blockquotes in this EPUB at all.

Fix: walk find_all(["p", "li"]) in document order so bullet entries
slot in right after their "Vocabulario" / list heading. Re-extracted
(2,646 → 3,326 paragraphs), re-translated all 118 jobs in parallel
Claude Code subagents. translate_chapters.py prompt template now tells
subagents to keep bilingual `palabra = meaning` lines verbatim — both
sides already coexist on the line.

Bumped bookDataVersion to 2 so refreshBooksDataIfNeeded re-seeds.
Verified in simulator: all 13 chapter row sizes grew (e.g. ch6
18,295→20,951 chars).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:10:34 -05:00
Trey T 09e49bda2c Add Books — read EPUB-imported books in Practice with tap-to-define
New "Books" row in the Practice tab opens a library of bundled bilingual
books. Each chapter renders Spanish paragraph-by-paragraph; tap any
word for a definition sheet (DictionaryService with on-device AI
fallback), or toggle the toolbar button to swap to the pre-computed
English translation inline.

Local-only Book + BookChapter SwiftData models added to the local
container schema (reset version bumped to 5). DataLoader.seedBooks
walks the bundle for `book_*.json` resources, so future books drop in
without touching app code — just bundle a new JSON and bump
bookDataVersion.

First book: Olly Richards' "Spanish Short Stories For Beginners
Vol 2" — 13 chapters, 2,646 paragraphs, bilingual.

Scripts/books/ is the repeatable pipeline for future EPUBs:
extract_epub.py → translate_chapters.py (per-chapter resumable jobs) →
bundle_book.py. Translation is done by parallel Claude Code subagents
reading per-job input files and writing output files — no API key
required, matching the pattern used for the textbook vocab vision
pass. See Scripts/books/README.md for the full how-to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:21:44 -05:00
Trey T ade091f108 Add Extra Study — star cards during review, study just the marked ones
Per-week "Extra Study (N)" row appears at the end of each LanGo week
section when at least one card is marked. Cards are marked from inside
VocabFlashcardView via a star next to the speaker on reveal. Marks are
keyed by the same SHA256 hash CourseReviewCard uses, so a mark and its
SRS state describe the same logical card.

ExtraStudyMark is CloudKit-synced (private DB), with uniqueness enforced
by fetch-or-create on id since CloudKit forbids @Attribute(.unique).
Skipped for textbook courses: DeckStudyView nils out the mark context
when the deck's courseName matches a TextbookChapter, and CourseView
hides the row when the active course is a textbook — so there are no
orphan marks the user can't reach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:38:39 -05:00
Trey T 05a0cc0d17 Issue #32 cleanup — drop the last 5 mis-oriented vocab pairs
Two small fixes after the LLM-vision pass:

1. merge_pdf_into_book.py — when the LLM classifies an image as 'hybrid'
   but extracts zero pairs (e.g., a conjugation table whose only English
   text is on the section header that was excluded by the prompt rules),
   respect that decision instead of falling through to the bbox/heuristic
   pipeline. Previously: 1 chapter-2 estar conjugation table generated
   4 bad pairs from the heuristic fallback.

2. fix_vocab.py language_score — recognize Spanish present-perfect
   ('he tenido', 'He andado por este pueblo') as Spanish. The classifier
   was treating the auxiliary 'he'/'has'/'ha' as English subject pronouns,
   producing false-positive mis-orientation flags on 4 chapter-15/20/23
   present-perfect example tables.

Result: mis-oriented vocab pairs across the book go from 5 → 0.
textbookDataVersion bumped to 14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:52:53 -05:00
Trey T f368c24ad6 Fixes #32 — LLM vision pass for vocab pairs, fixes scrambled English/Spanish
The bbox-OCR pipeline mis-paired ~114 vocab tables across the book — the
chapter 7 "Other Idioms" image (issue #32) being the most visible.
Three failure modes were collapsing the data:
  1) classifier blind to subject pronouns ("yo", "I", etc.)
  2) right-then-left OCR reads on 2-col tables
  3) Y-cluster drift on multi-line cells in 4-col layouts

Replaced the entire vocab-extraction tier with a Claude vision pass over
all 931 vocab images. Output is keyed by image with three classifications:
  - pair_table       (extract all Spanish↔English pairs)
  - reference_only   (Spanish-only conjugation tables — no pairs, UI shows
                      the flat OCR lines as a reference list instead)
  - hybrid           (some header pairs + reference content beneath; only
                      the genuine pairs become cards)

merge_pdf_into_book.py now picks pair source by priority:
  llm-vision → bounding-box OCR → block-alternation heuristic.

Numbers (across the whole book):
  - mis-oriented tables: 114 → 5
  - quarantined cards:   250 → 2
  - extracted pairs:     2832 → 4569

textbookDataVersion bumped to 13. Per-batch agent outputs gitignored
under Conjuga/Scripts/textbook/paired_vocab_llm/ — only the merged
paired_vocab_llm.json (also gitignored) is needed to rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:48:04 -05:00
Trey T 90aea92fba Fix Full Table eligibility — accept ordinary verbs, reject orto
The eligibility filter required every form's regularity tag to equal
"regular", but the data uses four labels:
  - regular   (179 forms — curated paradigm verbs)
  - ordinary  (50,992 forms — pattern-following verbs like hablar, comer)
  - irregular (8,653)
  - orto      (176 — orthographic spelling changes like busqué)

Result was a 27-combo eligible pool, ~26 of which were -ir verbs in
present tense — every Full Table prompt landed on the same handful of
verbs.

Pulled the rule into a SharedModels function (FullTableEligibility) so
it's testable in isolation. Accepts "regular" + "ordinary" (both mean
"follows the pattern"); rejects "irregular" and "orto". 9 unit tests
cover the matrix including edge cases (incomplete forms, mixed labels,
unknown values).

PracticeSessionService.makePromptIfFullyRegular now delegates to
FullTableEligibility, sorting forms by personIndex so the regularity
array lines up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:26:34 -05:00
Trey t dce2cc1f51 Make Full Table level-agnostic, fix the streak system end-to-end
Full Table (issue from chat): drop the level filter — Full Table tests
regular conjugation patterns, not vocabulary recognition, so restricting
to Basic-level verbs collapsed the eligible pool to two combos
(vivir present, ir future). Pool now draws from all 1,750 verbs. Random
sampling first; if 40 attempts fail we fall through to a deterministic
shuffled scan that guarantees finding any eligible (verb, tense) combo
when one exists. Returning nil now happens only when the user's filters
genuinely produce zero eligible prompts. The view replaces its silent
blank screen with a ContentUnavailableView pointing at the settings
that need adjusting. FeatureReferenceView documents the level exception.

Streak (issue #31 follow-up): activity recording was scoped to flashcard
and Full Table reviews only, so spending an hour on textbook work,
guides, videos, or AI chat could break a "streak" that the dashboard
kept displaying as if it were intact. Three fixes:

  1. Extract ReviewStore.recordActivity(context:) — a streak-only entry
     point that any user-initiated learning action can call.
  2. Add UserProgress.validateStreakIfStale(today:context:) — resets a
     broken currentStreak to 0 immediately, called from app launch and
     dashboard appear so the displayed number is never a lie.
  3. DailyLog formatter pins POSIX locale + current timezone so the
     yyyy-MM-dd strings can't drift across locales.

Wired recordActivity into every previously-silent learning action: chat
send, story-quiz completion, textbook exercise submit, grammar exercise
completion, course-deck study finish, week test / checkpoint save,
listening + pronunciation check, cloze quiz completion, lyrics word
lookup, video stream / play / download success, sentence-builder check,
and course-vocab SRS rate (which was bypassing ReviewStore entirely).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:24:27 -05:00
Trey T 06b47d37cf Fix video action button wrapping + bundle textbook seed JSONs
- VideoActionsView: stack icon over title via a custom label style so
  the Stream/Download/Play row fits iPhone width without wrapping
  mid-word ("Stre am", "Down load").
- project.yml: add textbook_data.json and textbook_vocab.json to the
  Conjuga target's Copy Bundle Resources. They were missing, so
  DataLoader.seedTextbookData early-returned every launch and the
  textbook never appeared in the Book tab. Regenerated Conjuga.xcodeproj.
2026-04-24 17:32:36 -05:00
Trey t f993bfbb96 Refresh README with recent features
Add YouTube video integration, reflexive-verb list + practice-pool
filters, verb-detail example sentences, lyrics word lookup. Bump iOS /
Xcode requirements to 26. Correct grammar-note count and document the
YouTubeKit + AVFoundation muxing architecture plus the video-curation
script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:33:41 -05:00
Trey t fcb907718a Re-curate videos toward preferred channels
Swap 24 tense-guide / grammar-note videos to The Language Tutor's
numbered lesson series where a matching lesson exists, filling the two
remaining gaps (ind_preterito_anterior → Lesson 65, estar-gerund-
progressive → Lesson 113). All 32 TLT picks preserved on this pass.

For the non-TLT slots, prefer BaseLang's beginner lesson series where a
topic-specific video exists: ser-vs-estar, preterite-vs-imperfect,
subjunctive-triggers, object-pronouns, conditional-if-clauses,
tener-expressions, future-vs-ir-a, possessive-adjectives,
irregular-yo-verbs, and stem-changing-verbs.

Retire both Tell Me In Spanish videos (personal-a → castellano4U,
types-of-irregular-verbs → Master IRREGULAR VERBS Complete Lesson).

Generator header note clarifies that "not available on this app" rows
are a transient yt-dlp extraction limit — videos still play when tapped
in the app via the Stream button, which opens youtube.com externally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:24:53 -05:00
Trey t 9c7033d1b4 Add curated-videos markdown report + generator script
youtube_videos.md lists every entry in youtube_videos.json with its
tense-guide / grammar-note id, title, channel, upload date, duration,
views, and likes (where public). Also flags the two topics with no
curated video so the gap is auditable in one place.

generate_videos_markdown.py queries yt-dlp in parallel for each unique
videoId and writes the markdown. Rerun when curation changes. One
current entry (saber-vs-conocer → j87i7MVCvIE) is now marked Private
Video — needs re-curation as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:07:41 -05:00
Trey t 0a099c3fc9 Fixes #30 — Downloaded videos include audio via adaptive-stream muxing
YouTubeKit returns stream URLs only; its own README punts on the
progressive-vs-adaptive problem. Most modern YouTube videos above 360p
serve video and audio as separate DASH tracks, so the previous "any MP4"
fallback silently grabbed a video-only track and the downloaded file had
no audio.

VideoDownloadService now resolves a DownloadPlan up front. Progressive
MP4 (when one exists) still streams straight to the final destination.
Otherwise the service downloads the best MP4 video track + best M4A
audio track to temp files, then combines them into a single MP4 via
AVMutableComposition + AVAssetExportSession. Passthrough preset first
(lossless container rewrite) with a fallback to highestQuality if the
codec combination requires re-encoding. Temp files are cleaned up on
both success and failure.

DownloadStatus replaces the bare Double progress so the UI can show
per-phase labels (Video %, Audio %, Finalizing…). Muxing progress is
rendered as an indeterminate spinner since AVAssetExportSession's
progress property doesn't cross actor boundaries cleanly.

Also retires the deprecated Stream.subtype comparison in favor of
Stream.fileExtension, matching YouTubeKit's current API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:00:16 -05:00
Trey t 57f945a4d3 Fixes #29 — Delete downloaded video inline on the guide detail view
The middle button in the Stream/Download/Play row now cycles through the
full download lifecycle instead of ending at a disabled "Downloaded"
checkmark. Once a video is on disk the button becomes a red, destructive
"Delete" with a trash icon; tapping presents a confirmation dialog, and
confirming removes the file + SwiftData row, flipping the button back to
"Download" and disabling Play.

Settings → Downloaded Videos retains the swipe-delete and "Delete all"
affordances for bulk management.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 06:46:51 -05:00
Trey t 5777a210cd Fixes #21 — Curated YouTube videos per guide + grammar item
Each of the 56 tense guides and grammar notes gets a curated YouTube video
attached (54 with picks, 2 silent nulls on rare / hard-to-find topics).
Users can stream in YouTube/Safari, download via YouTubeKit for offline
viewing, or play the local MP4 full-screen via AVPlayer.

YouTubeVideoStore loads the bundled youtube_videos.json at launch and serves
lookups by tense id or grammar note id. VideoDownloadService resolves the
best progressive MP4 stream off the main actor (YouTubeKit isn't Sendable),
writes to documents/videos/<videoId>.mp4, and records a DownloadedVideo row
in the local SwiftData container so the app knows what's on disk across
launches.

VideoActionsButtonRow is the unified UI for both detail views: three large
buttons — Stream (red, always enabled), Download (blue, disabled while in
flight and after completion, shows progress), Play (green, enabled only
when downloaded). Full-screen cover on tap. Settings gains a Downloaded
Videos list with swipe-delete, total-size summary, and a 500 MB warning.

Local store reset version bumped to 4 for the new DownloadedVideo schema.

Known fragility: YouTubeKit scrapes YouTube's private stream API and will
break when YouTube changes their internal format. Streaming keeps working
regardless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:51:19 -05:00
Trey t 98badc98ad Fixes #28 — Curated reflexive verb list on detail + practice filter
Bundles the 100 most common reflexive verbs from spanishwithdaniel.com as a
canonical list and wires it through the UI. Compound list entries (recibirse
/ graduarse, equivocarse / confundirse) are split. Trailing prepositions and
set-phrase completions are captured as usageHint (e.g. acordarse "de",
ponerse "de acuerdo").

ReflexiveVerbStore loads the JSON at launch and exposes lookups by base
infinitive, both via @Environment for SwiftUI and a static shared instance
for services. Verbs whose bare infinitive isn't in the list skip the UI
treatment silently.

VerbDetailView shows a new Reflexive section with the reflexive infinitive,
usage hint, and English meaning when there is a match. VerbListView gains a
"Reflexive verbs only" filter alongside the existing Level and Irregularity
filters. Settings adds the same flag so it also constrains the practice
pool; PracticeSessionService applies the reflexive filter in all six pick
paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:56:04 -05:00
Trey t 4093b5a7f3 Fixes #27 — AI-generated example sentences on verb detail
Tapping a verb now shows six example sentences beneath the conjugation table,
one per core tense (Present, Preterite, Imperfect, Future, Present Subjunctive,
Imperative). Each example renders the tense label, Spanish sentence, and
English translation in the DeckStudyView style.

Generation uses Foundation Models with a @Generable schema that pins each
response to the requested tenseId and forces tú/nosotros subjects for
imperatives. Results are cached as JSON in the Caches directory keyed by
verb id (DictionaryService pattern); cache misses regenerate on demand.

Devices without Apple Intelligence see an inline notice instead of the
loading state. No network dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:57:32 -05:00
Trey t 5d3accb2c0 Fixes #26 — Multi-select levels and irregular-type practice filter
UserProgress gains selectedLevelsBlob and enabledIrregularCategoriesBlob
(mirrors the existing tense-blob pattern). The multi-level setter keeps the
legacy selectedLevel String in sync with the highest-ranked selection, so
widget sync, AI scenarios, and achievement checks keep working unchanged.
Legacy single-level users are migrated on first read.

Settings replaces the level Picker with per-level toggles and adds an
Irregular Types section with three toggles. Practice pool is the literal
intersection: empty levels means zero results, empty irregular categories
means no irregularity constraint.

Pure filter logic lives in SharedModels (PracticeFilter, VerbLevel.highest)
and is covered by 20 Swift Testing cases. ReferenceStore delegates so the
intersection behavior is unit-tested without a ModelContainer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:36:25 -05:00
Trey t 3d8cbccc4e Fixes #25 — Long-press lyric words for definition and tense
Tokenize Spanish lyric lines into a flow layout of underlined, long-pressable
words. Long-press (0.35s) opens a sheet with base form, English, part of
speech, and a Tense · person row for verbs. Unknown words silently no-op.
English gloss lines remain untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:18:15 -05:00
Trey t cc6ec70ed9 Update README and app_features to reflect current feature set
README: expanded from the original terse list into categorized sections
covering the six conjugation practice modes, textbook reader, AI chat
and stories, listening/pronunciation, cloze, lyrics, vocab SRS, offline
dictionary, grammar notes/exercises, and CloudKit sync. Architecture
section now documents the dual local/cloud SwiftData stores with the
App Group ID, the widget-schema-must-match requirement, and the
Scripts/textbook extraction pipeline.

app_features.md: added a full Conjuga section (practice modes, verb
reference, grammar, dictionary, sync, widgets, data counts) alongside
the existing ConjuGato and Conjuu ES analyses; added Conjuga as a
first column in the comparison table with rows for the new capability
axes (AI, textbook, speech, offline dictionary, lyrics, CloudKit,
widgets); added a "Conjuga excels at" strengths section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:36:15 -05:00
Trey t d99d88e73c Add CLAUDE.md with project rules
Codifies the rule that Claude must not run git commit or git push
without an explicit request from the user in the current turn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:31:14 -05:00
Trey t 8e1c9b6bf1 Make textbook data self-heal after widget schema wipes
Root cause of the repeatedly-disappearing textbook: both widget timeline
providers were opening the shared local SwiftData store with a schema
that omitted TextbookChapter. On each widget refresh SwiftData
destructively migrated the store to match the widget's narrower schema,
dropping the ZTEXTBOOKCHAPTER rows (and sometimes the table itself).
The app then re-created an empty table on next open, but
refreshTextbookDataIfNeeded skipped re-seeding because the UserDefaults
version flag was already current — leaving the store empty indefinitely.

Three changes:

1. Widgets (CombinedWidget, WordOfDayWidget): added TextbookChapter to
   both schema arrays so they match the main app. Widget refreshes will
   no longer drop the entity.

2. DataLoader.refreshTextbookDataIfNeeded: trigger now considers BOTH
   the version flag and the actual on-disk row count. If rows are
   missing for any reason (past wipes, future subset-schema openers,
   corruption), the next launch re-seeds. Eliminates the class of bug
   where a version flag lies about what's really in the store.

3. StoreInspector: reports ZTEXTBOOKCHAPTER row count alongside the
   other entities so we can confirm state from logs.

Bumped textbookDataVersion to 12 so devices that were stuck in the
silent-failure state re-seed on next launch regardless of prior flag
value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:31:14 -05:00
Trey t d9ddaa4902 Fixes #23 — Add irregularity filter to the Verbs list
The toolbar Filter menu now has two sections:
- Level (existing)
- Irregularity: Any Irregular / Spelling Change / Stem Change /
  Unique Irregular

Filters combine, so "Basic" + "Unique Irregular" narrows to the
foundational ser/ir/haber-class verbs. Categories are derived at load
time from existing IrregularSpan rows using the same spanType ranges
already used by PracticeSessionService (1xx spelling, 2xx stem, 3xx
unique), so no schema or data changes are required.

UI additions:
- Per-row icons (star / arrows / I-beam) show each verb's
  irregularity categories at a glance, tinted by type.
- When any filter is active, a chip bar appears under the search
  field showing the active filters (tap to clear) and the resulting
  verb count.
- Filter toolbar icon fills when any filter is applied.

Data coverage: 614 / 1750 verbs are flagged irregular — 411 spelling,
275 stem-change, 67 unique — consistent with canonical lists from
SpanishDict, Lawless Spanish, and Wikipedia.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:29:46 -05:00
Trey t cdf1e05c4c Fixes #22 — Skip irregular verbs in Full Table practice
randomFullTablePrompt now rejects any (verb, tense) combo where any
form has regularity != "regular" and keeps retrying (40 attempts) until
a fully-regular combo is picked. Full Table practice is meant for
drilling regular conjugation patterns — irregular tables belong in the
Irregularity Drills mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:49:46 -05:00
Trey t 455df18dad Stop silent save failures from locking out textbook re-seeds
Two bugs were causing chapters to disappear on every relaunch:

1. seedTextbookData used `try? context.save()` (swallowing errors) and
   returned `inserted > 0`, so a failed save still reported success.
   Callers then bumped UserDefaults textbookDataVersion and subsequent
   launches skipped the re-seed entirely — with no rows on disk.

2. refreshTextbookDataIfNeeded wiped chapters via the batch-delete API
   `context.delete(model: TextbookChapter.self)`, which hits the store
   directly without clearing the context's .unique-id index. Re-inserting
   chapters with the same ids could then throw a unique-constraint error
   on save — also silently eaten by `try?`.

Fixes:
- seedTextbookData now uses do/catch around save(), returns false on
  error, and verifies persistence via fetchCount before returning true.
- refreshTextbookDataIfNeeded fetches and deletes chapters individually
  so the context tracks the deletion cleanly; wipe save is also now
  checked and bails early on failure.
- Bumped textbookDataVersion to 11 so devices poisoned by the previous
  silent-failure path retry on next launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:33:23 -05:00
Trey t 3c5600f562 Fix textbook section missing on installs that predate bundled JSON
Earlier launches (before cd491bd bundled textbook_data.json) ran
seedTextbookData, got "not bundled — skipping", and still bumped
UserDefaults textbookDataVersion to 9. Subsequent launches then
short-circuited in refreshTextbookDataIfNeeded and never re-seeded,
so ZTEXTBOOKCHAPTER stayed empty and the Course tab hid the section.

- seedTextbookData now returns Bool (true only when chapters inserted).
- Both call sites only write the version key on success, so a missing
  or unparseable bundle no longer locks us out of future retries.
- Bumped textbookDataVersion to 10 to force existing installs to
  re-seed on next launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:39:47 -05:00
Trey T 5f90a01314 Render textbook vocab as paired Spanish→English grid
Previously the chapter reader showed vocab tables as a flat list of OCR
lines — because Vision reads columns top-to-bottom, the Spanish column
appeared as one block followed by the English column, making pairings
illegible.

Now every vocab table renders as a 2-column grid with Spanish on the
left and English on the right. Supporting changes:

- New ocr_all_vocab.swift: bounding-box OCR over all 931 vocab images,
  cluster lines into rows by Y-coordinate, split rows by largest X-gap,
  detect 2- / 3- / 4-column layouts automatically. ~2800 pairs extracted
  this pass vs ~1100 from the old block-alternation heuristic.
- merge_pdf_into_book.py now prefers bounding-box pairs when present,
  falls back to the heuristic, embeds the resulting pairs as
  vocab_table.cards in book.json.
- DataLoader passes cards through to TextbookBlock on seed.
- TextbookChapterView renders cards via SwiftUI Grid (2 cols).
- fix_vocab.py quarantine rule relaxed — only mis-pairs where both
  sides are clearly the same language are removed. "unknown" sides
  stay (bbox pipeline already oriented them correctly).

Textbook card count jumps from 1044 → 3118 active pairs.
textbookDataVersion bumped to 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:58:41 -05:00
Trey T cd491bd695 Bundle textbook JSON so fresh clones build without re-running pipeline
The pbxproj references textbook_data.json and textbook_vocab.json as Copy
Bundle Resources, so xcodebuild fails if they're missing. Committing the
generated output keeps the repo self-sufficient — regenerate via
Conjuga/Scripts/textbook/run_pipeline.sh when content changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:28:45 -05:00
Trey t df96a9e540 Merge branch 'main' of gitea.treytartt.com:admin/Spanish 2026-04-19 15:23:01 -05:00
Trey t c73762ab9f Add tappable word lookup to chat bubbles
Assistant messages now render each word as a button. Tap shows a sheet
with base form, English translation, and part of speech. Dictionary
lookup first; falls back to Foundation Models (@Generable ChatWordInfo)
for words not in the local dictionary. Results cached per-session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:22:59 -05:00
Trey t f809bc2a1d Fix speech recognition crash from audio format mismatch
Switch audio session to .record-only, use nil tap format so the system
picks a compatible format, and route through AVAudioEngine with a 4096
buffer. Avoids the mDataByteSize(0) assertion seen on some devices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:22:45 -05:00
Trey T 63dfc5e41a Add textbook reader, exercise grading, stem-change toggle, extraction pipeline
Major changes:
- Textbook UI: chapter list, reader, and interactive exercise view (keyboard
  + Apple Pencil) surfaced under the Course tab. 30 chapters, 251 exercises.
- Stem-change conjugation toggle on Week 4 flashcard decks (E-IE, E-I, O-UE).
  Uses existing VerbForm + IrregularSpan data to render highlighted present
  tense conjugations inline.
- Deterministic on-device answer grader with partial credit (correct / close
  for accent-stripped or single-char-typo / wrong). 11 unit tests cover it.
- SharedModels: TextbookChapter (local), TextbookExerciseAttempt (cloud-
  synced), AnswerGrader helpers. Bumped schema.
- DataLoader: textbook seeder (version 8) + refresh helpers that preserve
  LanGo course decks when textbook data is re-seeded.
- Local extraction pipeline in Conjuga/Scripts/textbook/ — XHTML chapter
  parser, answer-key parser, macOS Vision image OCR + PDF page OCR, merger,
  NSSpellChecker validator, language-aware auto-fixer, and repair pass that
  re-pairs quarantined vocab rows using bounding-box coordinates.
- UI test target (ConjugaUITests) with three tests: end-to-end textbook
  flow, all-chapters screenshot audit, and stem-change toggle verification.

Generated textbook content (textbook_data.json, textbook_vocab.json) and
third-party source files are gitignored — re-run Scripts/textbook/run_pipeline.sh
locally to regenerate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:12:55 -05:00
Trey t 5ba76a947b Bump courseDataVersion to 7 after merging Gitea course data updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:41:55 -05:00
Trey t bb596b19bd Merge branch 'main' of gitea.treytartt.com:admin/Spanish 2026-04-16 08:40:17 -05:00
Trey t 47a7871c38 Add 13 new grammar notes with 1010 exercises from video extraction
Scraped a 4h Spanish fundamentals YouTube video (transcript + OCR on
14810 frames), extracted structured content across 52 chapters, and
generated fill-in-the-blank quizzes for every grammar topic.

- 13 new GrammarNote entries (articles, possessives, demonstratives,
  greetings, poder, al/del, prepositional pronouns, irregular yo,
  stem-changing, stressed possessives, present/future perfect, present
  indicative conjugation)
- 1010 generated exercises across all 36 grammar notes (new + existing)
- Fix tense guide parser to handle unnumbered *Usages* blocks
- Rewrite 6 broken tense guide bodies (imperative, subj pluperfect,
  subj future) with numbered usage format
- Bump courseDataVersion 5→6 with TenseGuide refresh on upgrade
- Add docs/spanish-fundamentals/ with raw transcripts, polished notes,
  structured JSON, and exercise data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:40:05 -05:00
admin b17fb49d49 Merge pull request 'Add English translations to exceptional yo forms' (#20) from issue/19-exceptional-yo-english into main
Reviewed-on: #20
2026-04-16 08:03:35 -05:00
439 changed files with 193863 additions and 1186 deletions
+23
View File
@@ -34,3 +34,26 @@ Pods/
screens/
conjugato/
conjuu-es/
# Video scraping pipeline (kept locally for reruns, not committed)
scrape/
*.webm
*.mp4
*.mkv
# Third-party textbook sources (not redistributable)
*.pdf
*.epub
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.
# Scripts are committed; their generated outputs are not.
Conjuga/Scripts/textbook/*.json
Conjuga/Scripts/textbook/review.html
Conjuga/Scripts/textbook/paired_vocab_llm/
# Note: the app-bundle copies (Conjuga/Conjuga/textbook_{data,vocab}.json)
# ARE committed so `xcodebuild` works on a fresh clone without first running
# the pipeline. They're regenerated from the scripts when content changes.
+5
View File
@@ -0,0 +1,5 @@
# Project rules
## Git
- **Never run `git commit` or `git push` without an explicit request from the user in the current turn.** File edits are fine; committing and pushing are not. Wait to be told.
+364 -92
View File
@@ -8,92 +8,144 @@
/* Begin PBXBuildFile section */
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.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 */; };
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
0E49DCF141F81D1C3A94C459 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */; };
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
2088B73D83D4D6A6CABEFBCE /* AdjectiveReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */; };
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; };
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 */; };
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.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 */; };
2B76DE203AEEBAE4536BD4F0 /* NounMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */; };
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.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 */; };
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 */; };
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.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 */; };
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; };
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
44C6084491E3056A4C3A890B /* AdjectiveMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */; };
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; };
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */; };
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.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 */; };
55FBABFE026058CFE8B40CB6 /* LexemeReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */; };
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.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 */; };
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; };
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */; };
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */; };
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */; };
6C0C71E0D56C199C375609AC /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA4750E84A7FA51532407CF /* BookLibraryView.swift */; };
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.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 */; };
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.swift */; };
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; };
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.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 */; };
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 */; };
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; };
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; };
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.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 */; };
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
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 */; };
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */ = {isa = PBXBuildFile; fileRef = 3540936F058728CFD87B1A1E /* textbook_vocab.json */; };
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D1904DF07E0A6816134CF3 /* ListeningView.swift */; };
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; };
AEFD25146A64DF9A07FE32B4 /* LexemeReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */; };
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; };
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */; };
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */; };
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
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 */; };
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 */; };
C7952958B55BAD218CB3ABC5 /* BookSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */; };
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.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 */; };
D3328F0C9CF6A2CB154A85B3 /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */; };
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.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, ); }; };
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C168499F60BC7AFE5100C572 /* BookChapterListView.swift */; };
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.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 */; };
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
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 */; };
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; };
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327659ABFD524514B6D2D505 /* StoryGenerator.swift */; };
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; };
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; };
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; };
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */; };
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3698CE7ACF148318615293E /* VocabReviewView.swift */; };
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A649B04B8B3C49419AD9219C /* ClozeView.swift */; };
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */; };
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */; };
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D535EF6988A24B47B70209A2 /* PronunciationService.swift */; };
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2179562E54E148C98219D /* ListeningView.swift */; };
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10603F454E54341AA4B9931 /* ConversationService.swift */; };
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */; };
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */; };
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */; };
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -121,26 +173,40 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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; };
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; };
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.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>"; };
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
3644B5ED77F29A65877D926A /* reflexive_verbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reflexive_verbs.json; sourceTree = "<group>"; };
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; };
39908548430FDF01D76201FB /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; };
3A96C065B8787DEC6818E497 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
@@ -149,42 +215,81 @@
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; };
43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; };
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = "<group>"; };
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterListView.swift; sourceTree = "<group>"; };
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.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>"; };
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>"; };
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>"; };
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>"; };
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.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>"; };
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.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>"; };
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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; };
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>"; };
A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.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>"; };
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; 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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.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; };
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>"; };
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>"; };
@@ -192,26 +297,24 @@
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -228,6 +331,7 @@
buildActionMask = 2147483647;
files = (
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -238,14 +342,22 @@
isa = PBXGroup;
children = (
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */,
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */,
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */,
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
BC273716CD14A99EFF8206CA /* course_data.json */,
7E6AF62A3A949630E067DC22 /* Info.plist */,
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
539736EB2AB8D149ED0F9C39 /* textbook_data.json */,
3540936F058728CFD87B1A1E /* textbook_vocab.json */,
7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */,
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */,
2610354CB0D62BD8A19BEC20 /* CourseMaterials */,
353C5DE41FD410FA82E3AED7 /* Models */,
23B49FBE9B44D8734D96625F /* Scripts */,
1994867BC8E985795A172854 /* Services */,
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
3C75490F53C34A37084FF478 /* ViewModels */,
A81CA75762B08D35D5B7A44D /* Views */,
);
@@ -255,8 +367,9 @@
0931AEB5B728C3A03F06A1CA /* Settings */ = {
isa = PBXGroup;
children = (
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */,
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */,
BCCC95A95581458E068E0484 /* SettingsView.swift */,
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
);
path = Settings;
sourceTree = "<group>";
@@ -274,28 +387,63 @@
isa = PBXGroup;
children = (
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */,
3A96C065B8787DEC6818E497 /* ConversationService.swift */,
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */,
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */,
EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */,
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
E10603F454E54341AA4B9931 /* ConversationService.swift */,
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
777C696A841803D5B775B678 /* ReferenceStore.swift */,
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */,
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */,
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */,
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */,
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
);
path = Services;
sourceTree = "<group>";
};
23B49FBE9B44D8734D96625F /* Scripts */ = {
isa = PBXGroup;
children = (
6D8FBC65B3D300DB2966E989 /* guide-enrichment */,
);
path = Scripts;
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>";
};
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
isa = PBXGroup;
children = (
@@ -308,15 +456,19 @@
isa = PBXGroup;
children = (
0313D24F96E6A0039C34341F /* DailyLog.swift */,
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */,
C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */,
AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */,
626873572466403C0288090D /* QuizType.swift */,
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */,
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */,
);
F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */,
);
path = Models;
sourceTree = "<group>";
};
@@ -341,6 +493,16 @@
path = ViewModels;
sourceTree = "<group>";
};
43E4D263B0AF47E401A51601 /* Stories */ = {
isa = PBXGroup;
children = (
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */,
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */,
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */,
);
path = Stories;
sourceTree = "<group>";
};
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = {
isa = PBXGroup;
children = (
@@ -360,53 +522,68 @@
5A23E5D4EFE8E46030CA9D77 /* Practice */ = {
isa = PBXGroup;
children = (
09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */,
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */,
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */,
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
10C16AA6022E4742898745CE /* TypingView.swift */,
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
9CD612E55440D22B877EA8FE /* Books */,
8FB89F19B33894DDF27C8EC2 /* Chat */,
895E547BEFB5D0FBF676BE33 /* Lyrics */,
8A1DED0596E04DDE9536A9A9 /* Stories */,
DFD75E32A53845A693D98F48 /* Chat */,
02B2179562E54E148C98219D /* ListeningView.swift */,
A649B04B8B3C49419AD9219C /* ClozeView.swift */,
);
43E4D263B0AF47E401A51601 /* Stories */,
730BD7F59F4C97D87EF98FB1 /* Vocab */,
);
path = Practice;
sourceTree = "<group>";
};
6D8FBC65B3D300DB2966E989 /* guide-enrichment */ = {
isa = PBXGroup;
children = (
7DE0F6354CF73BDA0CE728BA /* in */,
C36A0F3B1A4B759412ADB4E5 /* out */,
);
path = "guide-enrichment";
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>";
};
8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
isa = PBXGroup;
children = (
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */,
);
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */,
);
path = Guide;
sourceTree = "<group>";
};
DFD75E32A53845A693D98F48 /* Chat */ = {
isa = PBXGroup;
children = (
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */,
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */,
);
path = Chat;
sourceTree = "<group>";
};
8A1DED0596E04DDE9536A9A9 /* Stories */ = {
isa = PBXGroup;
children = (
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */,
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */,
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */,
);
path = Stories;
sourceTree = "<group>";
};
895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
isa = PBXGroup;
@@ -419,6 +596,26 @@
path = Lyrics;
sourceTree = "<group>";
};
8FB89F19B33894DDF27C8EC2 /* Chat */ = {
isa = PBXGroup;
children = (
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */,
79576893566932D2BE207528 /* ChatView.swift */,
);
path = Chat;
sourceTree = "<group>";
};
9CD612E55440D22B877EA8FE /* Books */ = {
isa = PBXGroup;
children = (
C168499F60BC7AFE5100C572 /* BookChapterListView.swift */,
CAA4750E84A7FA51532407CF /* BookLibraryView.swift */,
C20423155763A77A050727EC /* BookReaderView.swift */,
50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */,
);
path = Books;
sourceTree = "<group>";
};
A591A3B6F1F13D23D68D7A9D = {
isa = PBXGroup;
children = (
@@ -457,21 +654,27 @@
BE5A40BAC9DD6884C58A2096 /* Course */ = {
isa = PBXGroup;
children = (
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */,
E42513164C858C8CBE1A70AF /* CourseMaterialView.swift */,
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
833516C5D57F164C8660A479 /* CourseView.swift */,
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */,
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
39908548430FDF01D76201FB /* TextbookChapterView.swift */,
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */,
);
path = Course;
sourceTree = "<group>";
};
BFC1AEBE02CE22E6474FFEA6 /* Utilities */ = {
C36A0F3B1A4B759412ADB4E5 /* out */ = {
isa = PBXGroup;
children = (
);
path = Utilities;
path = out;
sourceTree = "<group>";
};
F605D24E5EA11065FD18AF7E /* Products */ = {
@@ -511,6 +714,7 @@
name = Conjuga;
packageProductDependencies = (
BCCBABD74CADDB118179D8E9 /* SharedModels */,
08D6313690BEE4E2F18EADC3 /* YouTubeKit */,
);
productName = Conjuga;
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
@@ -555,7 +759,6 @@
};
};
buildConfigurationList = F011A5DA3101F6F7CA7D2D95 /* Build configuration list for PBXProject "Conjuga" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
@@ -565,9 +768,11 @@
mainGroup = A591A3B6F1F13D23D68D7A9D;
minimizedProjectReferenceProxies = 1;
packageReferences = (
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */,
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = F605D24E5EA11065FD18AF7E /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
@@ -583,8 +788,23 @@
buildActionMask = 2147483647;
files = (
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 */,
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */,
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */,
0A29763DF0ED8F24730BCE5A /* vocab_lexemes.json in Resources */,
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -597,8 +817,23 @@
files = (
97EFCF6724CE59DC4F0274FD /* AchievementService.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 */,
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 */,
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */,
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */,
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */,
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */,
F22FD38D5CD6A89CC5940B0E /* CourseMaterialView.swift in Sources */,
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
@@ -607,15 +842,28 @@
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
381D007E8E35E5D91CD549A3 /* ExtraStudyStore.swift in Sources */,
DCA3805DCC1D03BEEDED73A8 /* ExtraStudyView.swift in Sources */,
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */,
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
D3328F0C9CF6A2CB154A85B3 /* GuideCrossLinks.swift in Sources */,
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */,
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
33E885EB38C3BB0CB058871A /* HandwritingView.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 */,
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
@@ -623,13 +871,18 @@
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
C1F84182F12EB5CFF32768B6 /* MainTabView.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 */,
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */,
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */,
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
@@ -637,39 +890,42 @@
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */,
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */,
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */,
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */,
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */,
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */,
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */,
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */,
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */,
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */,
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */,
27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
0E49DCF141F81D1C3A94C459 /* VerbReviewStore.swift in Sources */,
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
5EA89448E97D76CFC97DC4E2 /* VocabFlashcardPracticeView.swift in Sources */,
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
5BB8A0C1A8A6374B04F50781 /* VocabMultipleChoicePracticeView.swift in Sources */,
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
962B9B4FF15EADF2A30C5743 /* VocabSessionQueue.swift in Sources */,
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */,
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */,
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */,
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */,
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */,
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */,
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */,
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */,
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */,
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */,
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */,
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */,
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */,
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */,
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */,
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */,
);
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
217A29BCEDD9D44B6DD85AF6 /* Sources */ = {
@@ -941,7 +1197,23 @@
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/alexeichhorn/YouTubeKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.3.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
08D6313690BEE4E2F18EADC3 /* YouTubeKit */ = {
isa = XCSwiftPackageProductDependency;
package = E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */;
productName = YouTubeKit;
};
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
isa = XCSwiftPackageProductDependency;
productName = SharedModels;
@@ -0,0 +1,15 @@
{
"originHash" : "1b6ada17bf1104878f9520a6f7cb3cd84338c0da74dc3761cef075709d7df45d",
"pins" : [
{
"identity" : "youtubekit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/alexeichhorn/YouTubeKit.git",
"state" : {
"revision" : "65be95dbb1dbd749499e0638871568c823822276",
"version" : "0.4.8"
}
}
],
"version" : 3
}
+23 -12
View File
@@ -40,6 +40,9 @@ struct ConjugaApp: App {
@State private var syncMonitor = SyncStatusMonitor()
@State private var studyTimer = StudyTimerService()
@State private var dictionary = DictionaryService()
@State private var verbExampleCache = VerbExampleCache()
@State private var reflexiveStore = ReflexiveVerbStore()
@State private var youtubeVideoStore = YouTubeVideoStore()
let localContainer: ModelContainer
let cloudContainer: ModelContainer
@@ -67,14 +70,18 @@ struct ConjugaApp: App {
let cloudConfig = ModelConfiguration(
"cloud",
schema: Schema([
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
LexemeReviewCard.self, LexemeStudyGroup.self,
]),
cloudKitDatabase: .private("iCloud.com.conjuga.app")
)
cloudContainer = try ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
LexemeReviewCard.self, LexemeStudyGroup.self,
configurations: cloudConfig
)
@@ -111,6 +118,9 @@ struct ConjugaApp: App {
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
.environment(studyTimer)
.environment(dictionary)
.environment(verbExampleCache)
.environment(reflexiveStore)
.environment(youtubeVideoStore)
.task {
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
if needsSeed {
@@ -133,6 +143,11 @@ struct ConjugaApp: App {
localContainer: localContainer,
cloudContainer: cloudContainer
)
// Reset a broken streak immediately on launch so the
// dashboard never shows a stale number even if the user
// hasn't navigated to it yet.
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContainer.mainContext)
progress.validateStreakIfStale(context: cloudContainer.mainContext)
WidgetDataService.update(
localContainer: localContainer,
cloudContainer: cloudContainer
@@ -204,20 +219,16 @@ struct ConjugaApp: App {
}
private static func makeLocalContainer(at url: URL) throws -> ModelContainer {
// Built from the single shared model list so the app and the widget
// extension always open the store with an identical schema.
let schema = Schema(SharedStore.localSchemaModels)
let localConfig = ModelConfiguration(
"local",
schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
schema: schema,
url: url,
cloudKitDatabase: .none
)
return try ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
configurations: localConfig
)
return try ModelContainer(for: schema, configurations: localConfig)
}
private static func localStoreIsUsable(container: ModelContainer) -> Bool {
@@ -244,7 +255,7 @@ struct ConjugaApp: App {
/// Clears accumulated stale schema metadata from previous container configurations.
/// Bump the version number to force another reset if the schema changes again.
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
let resetVersion = 3 // bump: SavedSong moved to cloud container
let resetVersion = 6 // bump: Lexeme added to local container
let key = "localStoreResetVersion"
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.
+6 -10
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.conjuga.app.refresh</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -22,21 +26,13 @@
<string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.education</string>
<key>UILaunchScreen</key>
<dict/>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Conjuga uses speech recognition to check your Spanish pronunciation and transcribe what you say during listening exercises.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Conjuga needs microphone access to record your voice for pronunciation practice.</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.conjuga.app.refresh</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
+14 -5
View File
@@ -32,10 +32,21 @@ final class DailyLog {
}
}
static func dateString(from date: Date) -> String {
/// Defensive formatter: explicit POSIX locale + current timezone so date
/// strings can never drift due to locale formatting (e.g. Arabic numerals)
/// or implicit-zone shifts. The string format is timezone-naive
/// `yyyy-MM-dd`, which works because we only ever compare to other
/// strings produced by this same formatter.
private static func makeFormatter() -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
return formatter
}
static func dateString(from date: Date) -> String {
makeFormatter().string(from: date)
}
static func todayString() -> String {
@@ -43,8 +54,6 @@ final class DailyLog {
}
static func date(from string: String) -> Date? {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: string)
makeFormatter().date(from: string)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
import Foundation
/// Maps tense guides grammar notes for the Guide tab's cross-link chips.
/// The forward map is the curated source; the reverse map is derived once.
///
/// A tense ID appears here only if at least one grammar note in
/// `GrammarNote.allNotesIncludingGenerated` covers a concept directly tied
/// to that tense (forms, contrast, triggers, choice). Two tenses currently
/// have no aligned notes and don't appear: `ind_pluscuamperfecto` and
/// `ind_preterito_anterior`.
enum GuideCrossLinks {
/// Tense ID ordered grammar note IDs that go deeper on this tense.
/// Order matters the first chip is the most-relevant note for the
/// tense's primary teaching point.
static let relatedNotes: [String: [String]] = [
"ind_presente": [
"present-indicative-conjugation",
"irregular-yo-verbs",
"stem-changing-verbs",
"estar-gerund-progressive",
],
"ind_preterito": [
"preterite-vs-imperfect",
"stem-changing-verbs",
],
"ind_imperfecto": [
"preterite-vs-imperfect",
],
"ind_futuro": [
"future-vs-ir-a",
],
"ind_perfecto": [
"present-perfect-tense",
],
"ind_futuro_perfecto": [
"future-perfect-tense",
],
"cond_presente": [
"conditional-if-clauses",
],
"cond_perfecto": [
"conditional-if-clauses",
],
"subj_presente": [
"subjunctive-triggers",
"irregular-yo-verbs",
"stem-changing-verbs",
],
"subj_imperfecto_1": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_imperfecto_2": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_perfecto": [
"subjunctive-triggers",
],
"subj_pluscuamperfecto_1": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_pluscuamperfecto_2": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_futuro": [
"subjunctive-triggers",
],
"subj_futuro_perfecto": [
"subjunctive-triggers",
],
"imp_afirmativo": [
"commands-imperative",
],
"imp_negativo": [
"commands-imperative",
"subjunctive-triggers",
],
]
/// Grammar note ID tense IDs that point at this note, ordered by the
/// shared `TenseInfo.order` so chips appear in canonical conjugation
/// order.
static let relatedTenses: [String: [String]] = {
var inverse: [String: [String]] = [:]
for (tenseId, noteIds) in relatedNotes {
for noteId in noteIds {
inverse[noteId, default: []].append(tenseId)
}
}
for key in inverse.keys {
inverse[key]?.sort { lhs, rhs in
(TenseInfo.find(lhs)?.order ?? 999) < (TenseInfo.find(rhs)?.order ?? 999)
}
}
return inverse
}()
static func noteIds(forTense tenseId: String) -> [String] {
relatedNotes[tenseId] ?? []
}
static func tenseIds(forNote noteId: String) -> [String] {
relatedTenses[noteId] ?? []
}
}
@@ -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()
}
}
+27
View File
@@ -55,3 +55,30 @@ final class CourseReviewCard {
self.back = back
}
}
/// SRS record for verb-level vocab practice (EN ES infinitive recall),
/// separate from per-form `ReviewCard` so a user's vocab progress doesn't
/// collide with conjugation form mastery.
///
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
/// `VerbReviewStore` since CloudKit forbids `@Attribute(.unique)`.
@Model
final class VerbReviewCard {
var id: String = ""
var verbId: Int = 0
var easeFactor: Double = 2.5
var interval: Int = 0
var repetitions: Int = 0
var dueDate: Date = Date()
var lastReviewDate: Date?
init(verbId: Int) {
self.id = Self.makeId(verbId: verbId)
self.verbId = verbId
}
static func makeId(verbId: Int) -> String {
"verb-\(verbId)"
}
}
+118
View File
@@ -14,6 +14,7 @@ final class UserProgress {
var selectedLevel: String = "basic"
var showVosotros: Bool = true
var autoFillStem: Bool = false
var showReflexiveVerbsOnly: Bool = false
// Legacy CloudKit array-backed fields retained for migration compatibility.
var enabledTenses: [String] = []
@@ -21,6 +22,14 @@ final class UserProgress {
var enabledTensesBlob: String = ""
var unlockedBadgesBlob: String = ""
// Multi-select level + irregularity filters (Issue #26).
var selectedLevelsBlob: 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() {}
var selectedVerbLevel: VerbLevel {
@@ -44,6 +53,44 @@ final class UserProgress {
}
}
/// Levels currently enabled for practice. Multi-select per Issue #26.
/// Setting this also syncs `selectedLevel` to the highest-ranked selection so
/// legacy single-level consumers (widget, AI scenarios, word-of-day) stay consistent.
var selectedVerbLevels: Set<VerbLevel> {
get {
let raw = decodeStringArray(from: selectedLevelsBlob, fallback: [])
let decoded = Set(raw.compactMap(VerbLevel.init(rawValue:)))
if !decoded.isEmpty { return decoded }
// Pre-migration users: treat the single selectedLevel as the set.
if let legacy = VerbLevel(rawValue: selectedLevel) {
return [legacy]
}
return []
}
set {
let sorted = newValue.map(\.rawValue)
selectedLevelsBlob = Self.encodeStringArray(sorted)
selectedLevel = VerbLevel.highest(in: newValue)?.rawValue ?? VerbLevel.basic.rawValue
}
}
/// The single representative level for callers that need one value
/// (word-of-day widget, AI chat/story scenarios). Highest selected level.
var primaryLevel: VerbLevel {
VerbLevel.highest(in: selectedVerbLevels) ?? selectedVerbLevel
}
var enabledIrregularCategories: Set<IrregularSpan.SpanCategory> {
get {
let raw = decodeStringArray(from: enabledIrregularCategoriesBlob, fallback: [])
return Set(raw.compactMap(IrregularSpan.SpanCategory.init(rawValue:)))
}
set {
let sorted = newValue.map(\.rawValue)
enabledIrregularCategoriesBlob = Self.encodeStringArray(sorted)
}
}
func setTenseEnabled(_ tenseId: String, enabled: Bool) {
var values = Set(enabledTenseIDs)
if enabled {
@@ -54,12 +101,79 @@ final class UserProgress {
enabledTenseIDs = values.sorted()
}
func setLevelEnabled(_ level: VerbLevel, enabled: Bool) {
var values = selectedVerbLevels
if enabled {
values.insert(level)
} else {
values.remove(level)
}
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) {
var values = enabledIrregularCategories
if enabled {
values.insert(category)
} else {
values.remove(category)
}
enabledIrregularCategories = values
}
func unlockBadge(_ badgeId: String) {
var values = Set(unlockedBadgeIDs)
values.insert(badgeId)
unlockedBadgeIDs = values.sorted()
}
/// Resets `currentStreak` to zero if more than one day has passed since
/// the last recorded activity. Without this check the dashboard keeps
/// displaying a stale streak number for days after the user actually
/// stops practicing the underlying counter only updates on the *next*
/// practice action. Call from app launch and the dashboard's `.task`.
@MainActor
func validateStreakIfStale(today: Date = Date(), context: ModelContext) {
guard !todayDate.isEmpty else { return }
let todayString = DailyLog.dateString(from: today)
if todayDate == todayString { return }
guard let prevDate = DailyLog.date(from: todayDate) else { return }
let diff = Calendar.current.dateComponents([.day], from: prevDate, to: today)
if (diff.day ?? Int.max) > 1 && currentStreak != 0 {
currentStreak = 0
try? context.save()
}
}
func migrateLegacyStorageIfNeeded() {
if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty {
enabledTenseIDs = enabledTenses
@@ -67,6 +181,9 @@ final class UserProgress {
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
unlockedBadgeIDs = unlockedBadges
}
if selectedLevelsBlob.isEmpty, let legacy = VerbLevel(rawValue: selectedLevel) {
selectedVerbLevels = [legacy]
}
}
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
@@ -86,4 +203,5 @@ final class UserProgress {
}
return string
}
}
@@ -0,0 +1,89 @@
import Foundation
import SwiftData
/// The user's active vocab-flashcard study set, persisted and CloudKit-synced
/// so the same group of verbs follows them across launches and across devices.
/// A new group is created when the previous one is fully learned.
///
/// CloudKit-synced; uniqueness on `id` is enforced in code (CloudKit forbids
/// `@Attribute(.unique)`). There is one active standard group at a time.
@Model
final class VocabStudyGroup {
var id: String = ""
/// JSON-encoded `[StoredVocabEntry]` the in-session queue (un-graduated
/// verbs only) in order, each with its learning-step state.
var entriesJSON: Data = Data()
var learnedCount: Int = 0
var createdAt: Date = Date()
init(entriesJSON: Data, learnedCount: Int) {
self.id = Self.activeID
self.entriesJSON = entriesJSON
self.learnedCount = learnedCount
self.createdAt = Date()
}
/// Single active standard-session group.
static let activeID = "active-standard"
var entries: [StoredVocabEntry] {
(try? JSONDecoder().decode([StoredVocabEntry].self, from: entriesJSON)) ?? []
}
}
/// One verb's persisted spot in the study group.
struct StoredVocabEntry: Codable {
var verbId: Int
/// Raw value of `VocabSessionQueue.CardState`.
var state: String
}
/// Fetch / persist / clear the active study group. Operates on the cloud
/// context so the group syncs across devices.
struct VocabStudyGroupStore {
let context: ModelContext
/// The current active group, or nil. If duplicate records exist (two
/// devices both created one before sync settled), the newest wins.
func activeGroup() -> VocabStudyGroup? {
let id = VocabStudyGroup.activeID
let descriptor = FetchDescriptor<VocabStudyGroup>(
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
)
return (try? context.fetch(descriptor))?.first
}
/// Write the in-progress group, creating it if needed. If duplicate records
/// exist (two devices created a group before sync settled), the newest is
/// updated and the rest deleted so a single record survives.
func persist(entries: [StoredVocabEntry], learnedCount: Int) {
let data = (try? JSONEncoder().encode(entries)) ?? Data()
let id = VocabStudyGroup.activeID
let descriptor = FetchDescriptor<VocabStudyGroup>(
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
sortBy: [SortDescriptor(\VocabStudyGroup.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(VocabStudyGroup(entriesJSON: data, learnedCount: learnedCount))
}
try? context.save()
}
/// Remove the active group (and any duplicates) the set is finished.
func clear() {
let id = VocabStudyGroup.activeID
let descriptor = FetchDescriptor<VocabStudyGroup>(
predicate: #Predicate<VocabStudyGroup> { $0.id == id }
)
for group in (try? context.fetch(descriptor)) ?? [] {
context.delete(group)
}
try? context.save()
}
}
@@ -0,0 +1,10 @@
import Foundation
import SharedModels
/// Thin app-side wrapper around the SharedModels `AnswerGrader`. All logic
/// lives in SharedModels so it can be unit tested.
enum AnswerChecker {
static func grade(userText: String, canonical: String, alternates: [String] = []) -> TextbookGrade {
AnswerGrader.grade(userText: userText, canonical: canonical, alternates: alternates)
}
}
@@ -0,0 +1,222 @@
import AVFoundation
import Foundation
import Observation
/// Drives "read aloud" mode for `BookReaderView`. Wraps an
/// `AVSpeechSynthesizer` with a queue of paragraph utterances and exposes the
/// current paragraph/word index so the view can highlight the active word.
///
/// Skips vocabulary lines (`palabra = meaning`) since the synth pronounces the
/// `=` awkwardly and the bilingual gloss is reference material, not prose.
@MainActor
@Observable
final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
// MARK: - Observable state
private(set) var isReading: Bool = false
private(set) var isPaused: Bool = false
private(set) var currentParagraphIndex: Int? = nil
private(set) var currentWordIndex: Int? = nil
// MARK: - Configuration
var rate: Float = 0.45
var voiceIdentifier: String? = nil
// MARK: - Internals
/// Built on first use, not in `init`. `AVSpeechSynthesizer()` connects to
/// the system speech daemon, so allocating one per `BookReaderView` struct
/// construction (SwiftUI rebuilds the struct on every parent render) is a
/// real cost deferring it keeps controller construction cheap.
@ObservationIgnored
private lazy var synthesizer: AVSpeechSynthesizer = {
let synth = AVSpeechSynthesizer()
synth.delegate = self
return synth
}()
private var queue: [QueueEntry] = []
private var queueCursor: Int = 0
private var audioSessionConfigured = false
private struct QueueEntry {
let paragraphIndex: Int
let text: String
let wordRanges: [Range<String.Index>]
}
override init() {
super.init()
}
// MARK: - Public control
/// Start (or restart) reading the given paragraphs. Indexes in
/// `currentParagraphIndex` are positions in the original `paragraphs`
/// array vocab lines are skipped internally but the visible index space
/// matches what the caller passed.
func start(paragraphs: [String], from startIndex: Int = 0) {
stop()
configureAudioSession()
var entries: [QueueEntry] = []
for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
if Self.isVocabLine(p) { continue }
entries.append(QueueEntry(
paragraphIndex: idx,
text: p,
wordRanges: Self.wordRanges(in: p)
))
}
guard !entries.isEmpty else { return }
queue = entries
queueCursor = 0
isReading = true
isPaused = false
speakCurrent()
}
/// Pause immediately (no word boundary). Use this for tap-to-define so the
/// audio stops the moment the user taps.
func pause() {
guard isReading, !isPaused else { return }
synthesizer.pauseSpeaking(at: .immediate)
isPaused = true
}
func resume() {
guard isReading, isPaused else { return }
synthesizer.continueSpeaking()
isPaused = false
}
func stop() {
synthesizer.stopSpeaking(at: .immediate)
queue.removeAll()
queueCursor = 0
isReading = false
isPaused = false
currentParagraphIndex = nil
currentWordIndex = nil
deactivateAudioSession()
}
// MARK: - Vocab detection + word ranges
/// Vocabulary entries in the book are formatted `palabra = meaning`.
/// Reading them aloud says "palabra equals meaning" which is awkward, and
/// they're reference material, so the read-along skips them.
static func isVocabLine(_ paragraph: String) -> Bool {
paragraph.contains(" = ")
}
/// Word ranges that match the BookReaderView's space-split rendering
/// the visible word index N in a paragraph corresponds to wordRanges[N].
static func wordRanges(in text: String) -> [Range<String.Index>] {
var ranges: [Range<String.Index>] = []
var i = text.startIndex
while i < text.endIndex {
while i < text.endIndex && text[i] == " " {
i = text.index(after: i)
}
guard i < text.endIndex else { break }
let start = i
while i < text.endIndex && text[i] != " " {
i = text.index(after: i)
}
ranges.append(start..<i)
}
return ranges
}
// MARK: - Private
private func speakCurrent() {
guard queueCursor < queue.count else {
stop()
return
}
let entry = queue[queueCursor]
currentParagraphIndex = entry.paragraphIndex
currentWordIndex = nil
let utterance = AVSpeechUtterance(string: entry.text)
utterance.voice = resolveVoice()
utterance.rate = rate
utterance.pitchMultiplier = 1.0
utterance.postUtteranceDelay = 0.20
synthesizer.speak(utterance)
}
private func resolveVoice() -> AVSpeechSynthesisVoice? {
if let id = voiceIdentifier, let v = AVSpeechSynthesisVoice(identifier: id) {
return v
}
return AVSpeechSynthesisVoice(language: "es-ES")
}
private func configureAudioSession() {
guard !audioSessionConfigured else { return }
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .spokenAudio, options: [])
try session.setActive(true)
audioSessionConfigured = true
} catch {
print("[BookSpeech] audio session failed: \(error)")
}
}
/// Release audio focus on stop so the OS hands control back to whatever
/// app was playing before (music, podcast, etc.). Without this the
/// session stays "active" until the app is killed.
private func deactivateAudioSession() {
guard audioSessionConfigured else { return }
do {
try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
} catch {
print("[BookSpeech] audio session deactivation failed: \(error)")
}
audioSessionConfigured = false
}
private func handleWillSpeakRange(_ range: NSRange) {
guard queueCursor < queue.count else { return }
let entry = queue[queueCursor]
guard let stringRange = Range(range, in: entry.text) else { return }
let lower = stringRange.lowerBound
let idx = entry.wordRanges.firstIndex {
$0.lowerBound <= lower && lower < $0.upperBound
}
if let idx, idx != currentWordIndex {
currentWordIndex = idx
}
}
private func handleDidFinish() {
queueCursor += 1
if queueCursor < queue.count {
speakCurrent()
} else {
stop()
}
}
// MARK: - AVSpeechSynthesizerDelegate
nonisolated func speechSynthesizer(
_ synthesizer: AVSpeechSynthesizer,
willSpeakRangeOfSpeechString characterRange: NSRange,
utterance: AVSpeechUtterance
) {
Task { @MainActor in self.handleWillSpeakRange(characterRange) }
}
nonisolated func speechSynthesizer(
_ synthesizer: AVSpeechSynthesizer,
didFinish utterance: AVSpeechUtterance
) {
Task { @MainActor in self.handleDidFinish() }
}
}
+535 -3
View File
@@ -3,9 +3,18 @@ import SharedModels
import Foundation
actor DataLoader {
static let courseDataVersion = 6
static let courseDataVersion = 9 // bump: all 19 tense guides + 36 grammar notes enriched to teacher-handout depth
static let courseDataKey = "courseDataVersion"
static let textbookDataVersion = 14
static let textbookDataKey = "textbookDataVersion"
static let bookDataVersion = 7 // Lexeme table + WordGloss.gender added
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?
static func needsSeeding(container: ModelContainer) async -> Bool {
let context = ModelContext(container)
@@ -15,6 +24,12 @@ actor DataLoader {
let storedVersion = UserDefaults.standard.integer(forKey: courseDataKey)
if storedVersion < courseDataVersion { return true }
let textbookVersion = UserDefaults.standard.integer(forKey: textbookDataKey)
if textbookVersion < textbookDataVersion { return true }
let bookVersion = UserDefaults.standard.integer(forKey: bookDataKey)
if bookVersion < bookDataVersion { return true }
return false
}
@@ -133,6 +148,98 @@ actor DataLoader {
// Seed course data (uses the same mainContext so @Query sees it)
seedCourseData(context: context)
// Seed textbook data only bump the version key if the seed
// actually inserted rows, so a missing/unparseable bundle doesn't
// permanently lock us out of future re-seeds.
if seedTextbookData(context: context) {
UserDefaults.standard.set(textbookDataVersion, forKey: textbookDataKey)
}
if seedBooks(context: context) {
UserDefaults.standard.set(bookDataVersion, forKey: bookDataKey)
}
}
/// Re-seed books if the version has changed or the rows are missing.
static func refreshBooksDataIfNeeded(container: ModelContainer) async {
let shared = UserDefaults.standard
let context = ModelContext(container)
let existingCount = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
let storedVersion = shared.integer(forKey: bookDataKey)
let versionCurrent = storedVersion >= bookDataVersion
print("[DataLoader] refreshBooksDataIfNeeded: existing=\(existingCount) stored=\(storedVersion) target=\(bookDataVersion) versionCurrent=\(versionCurrent)")
if versionCurrent && existingCount > 0 { return }
if let existing = try? context.fetch(FetchDescriptor<Book>()) {
for book in existing { context.delete(book) }
}
if let existing = try? context.fetch(FetchDescriptor<BookChapter>()) {
for chapter in existing { context.delete(chapter) }
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: book wipe save failed: \(error)")
return
}
if seedBooks(context: context) {
shared.set(bookDataVersion, forKey: bookDataKey)
print("[DataLoader] Book data re-seeded to version \(bookDataVersion)")
} else {
print("[DataLoader] Book reseed produced no rows — leaving version key untouched")
}
}
/// Re-seed textbook data if the version has changed OR if the rows are
/// missing on disk. The row-count check exists because anything opening
/// this store with a subset schema (e.g. an out-of-date widget extension)
/// can destructively drop the rows without touching UserDefaults so a
/// pure version-flag trigger would leave us permanently empty.
static func refreshTextbookDataIfNeeded(container: ModelContainer) async {
let shared = UserDefaults.standard
let context = ModelContext(container)
let existingCount = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
let versionCurrent = shared.integer(forKey: textbookDataKey) >= textbookDataVersion
if versionCurrent && existingCount > 0 { return }
if versionCurrent {
print("Textbook data version current but store has \(existingCount) chapters — re-seeding...")
} else {
print("Textbook data version outdated — re-seeding...")
}
// Fetch + delete individually instead of batch delete. SwiftData's
// context.delete(model:) hits the store directly and doesn't always
// clear the unique-constraint index before the reseed's save runs,
// so re-inserting rows with the same .unique id can throw.
let textbookCourseName = "Complete Spanish Step-by-Step"
if let existing = try? context.fetch(FetchDescriptor<TextbookChapter>()) {
for chapter in existing { context.delete(chapter) }
}
let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.courseName == textbookCourseName }
)
if let decks = try? context.fetch(deckDescriptor) {
for deck in decks { context.delete(deck) }
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: textbook wipe save failed: \(error)")
return
}
if seedTextbookData(context: context) {
shared.set(textbookDataVersion, forKey: textbookDataKey)
print("Textbook data re-seeded to version \(textbookDataVersion)")
} else {
print("Textbook re-seed failed — leaving version key untouched so next launch retries")
}
}
/// Re-seed course data if the version has changed (e.g. examples were added).
@@ -145,14 +252,35 @@ actor DataLoader {
print("Course data version outdated — re-seeding...")
let context = ModelContext(container)
// Delete existing course data
// Delete existing course data + tense guides so they can be re-seeded
// with updated bodies from the bundled conjuga_data.json.
try? context.delete(model: VocabCard.self)
try? context.delete(model: CourseDeck.self)
try? context.delete(model: TenseGuide.self)
try? context.save()
// Re-seed
// Re-seed tense guides from the bundled JSON
if let url = Bundle.main.url(forResource: "conjuga_data", withExtension: "json"),
let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let guides = json["tenseGuides"] as? [[String: Any]] {
for g in guides {
guard let tenseId = g["tenseId"] as? String,
let title = g["title"] as? String,
let body = g["body"] as? String else { continue }
context.insert(TenseGuide(tenseId: tenseId, title: title, body: body))
}
try? context.save()
print("Re-seeded \(guides.count) tense guides")
}
// Re-seed course data
seedCourseData(context: context)
// Textbook's vocab decks/cards share the same CourseDeck/VocabCard
// entities, so they were just wiped above. Reseed them.
seedTextbookVocabDecks(context: context, courseName: "Complete Spanish Step-by-Step")
shared.set(courseDataVersion, forKey: courseDataKey)
print("Course data re-seeded to version \(courseDataVersion)")
}
@@ -319,4 +447,408 @@ actor DataLoader {
context.insert(reviewCard)
return reviewCard
}
// MARK: - Textbook seeding
@discardableResult
private static func seedTextbookData(context: ModelContext) -> Bool {
let url = Bundle.main.url(forResource: "textbook_data", withExtension: "json")
?? Bundle.main.bundleURL.appendingPathComponent("textbook_data.json")
guard let data = try? Data(contentsOf: url) else {
print("[DataLoader] textbook_data.json not bundled — skipping textbook seed")
return false
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[DataLoader] ERROR: Could not parse textbook_data.json")
return false
}
let courseName = (json["courseName"] as? String) ?? "Textbook"
guard let chapters = json["chapters"] as? [[String: Any]] else {
print("[DataLoader] ERROR: textbook_data.json missing chapters")
return false
}
var inserted = 0
for ch in chapters {
guard let id = ch["id"] as? String,
let number = ch["number"] as? Int,
let title = ch["title"] as? String,
let blocksRaw = ch["blocks"] as? [[String: Any]] else { continue }
let part = (ch["part"] as? Int) ?? 0
// Normalize each block to canonical keys expected by TextbookBlock decoder.
var normalized: [[String: Any]] = []
var exerciseCount = 0
var vocabTableCount = 0
for (i, b) in blocksRaw.enumerated() {
var out: [String: Any] = [:]
out["index"] = i
let kind = (b["kind"] as? String) ?? ""
out["kind"] = kind
switch kind {
case "heading":
if let level = b["level"] { out["level"] = level }
if let text = b["text"] { out["text"] = text }
case "paragraph":
if let text = b["text"] { out["text"] = text }
case "key_vocab_header":
break
case "vocab_table":
vocabTableCount += 1
if let src = b["sourceImage"] { out["sourceImage"] = src }
if let lines = b["ocrLines"] { out["ocrLines"] = lines }
if let conf = b["ocrConfidence"] { out["ocrConfidence"] = conf }
// Paired SpanishEnglish cards from the bounding-box extractor.
if let cards = b["cards"] as? [[String: Any]], !cards.isEmpty {
let normalized: [[String: Any]] = cards.compactMap { c in
guard let front = c["front"] as? String,
let back = c["back"] as? String else { return nil }
return ["front": front, "back": back]
}
if !normalized.isEmpty {
out["cards"] = normalized
}
}
case "exercise":
exerciseCount += 1
if let exId = b["id"] { out["exerciseId"] = exId }
if let inst = b["instruction"] { out["instruction"] = inst }
if let extra = b["extra"] { out["extra"] = extra }
if let prompts = b["prompts"] { out["prompts"] = prompts }
if let items = b["answerItems"] { out["answerItems"] = items }
if let freeform = b["freeform"] { out["freeform"] = freeform }
default:
break
}
normalized.append(out)
}
let bodyJSON: Data
do {
bodyJSON = try JSONSerialization.data(withJSONObject: normalized, options: [])
} catch {
print("[DataLoader] failed to encode chapter \(number) blocks: \(error)")
continue
}
let chapter = TextbookChapter(
id: id,
number: number,
title: title,
part: part,
courseName: courseName,
bodyJSON: bodyJSON,
exerciseCount: exerciseCount,
vocabTableCount: vocabTableCount
)
context.insert(chapter)
inserted += 1
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: textbook chapter save failed: \(error)")
return false
}
// Verify rows actually hit the store guards against the case where
// save returned cleanly but no rows were persisted.
let persisted = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
guard persisted > 0 else {
print("[DataLoader] ERROR: textbook seeded \(inserted) chapters but persisted count is 0")
return false
}
// Seed textbook-derived vocabulary flashcards as CourseDecks so the
// existing Course UI can surface them alongside LanGo decks.
seedTextbookVocabDecks(context: context, courseName: courseName)
print("Textbook seeding complete: \(inserted) chapters inserted, \(persisted) persisted")
return true
}
// MARK: - Books seeding
/// Walk the bundle for any `book_*.json` resources and seed `Book` +
/// `BookChapter` rows from each one. Returns true when at least one row
/// was inserted (mirrors `seedTextbookData`'s contract).
@discardableResult
private static func seedBooks(context: ModelContext) -> Bool {
let bookURLs = bundledBookJSONURLs()
guard !bookURLs.isEmpty else {
print("[DataLoader] no book_*.json bundled — skipping book seed")
return false
}
var insertedBooks = 0
for url in bookURLs {
guard let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[DataLoader] WARN: could not read \(url.lastPathComponent)")
continue
}
guard let slug = json["slug"] as? String,
let title = json["title"] as? String,
let chaptersRaw = json["chapters"] as? [[String: Any]] else {
print("[DataLoader] WARN: \(url.lastPathComponent) missing required fields")
continue
}
let author = (json["author"] as? String) ?? ""
let language = (json["language"] as? String) ?? "es"
// Pre-computed per-book glossary, keyed by cleaned word.
var glossary: [String: WordGloss] = [:]
if let glossaryRaw = json["glossary"] as? [String: [String: String]] {
for (word, fields) in glossaryRaw {
glossary[word] = WordGloss(
baseForm: fields["baseForm"] ?? word,
english: fields["english"] ?? "",
partOfSpeech: fields["partOfSpeech"] ?? "",
gender: fields["gender"]
)
}
}
let glossaryData = (try? JSONEncoder().encode(glossary)) ?? Data()
let book = Book(
slug: slug,
title: title,
author: author,
language: language,
chapterCount: chaptersRaw.count,
accentColorHex: accentHex(forSlug: slug),
glossaryJSON: glossaryData
)
context.insert(book)
insertedBooks += 1
for ch in chaptersRaw {
guard let number = ch["number"] as? Int,
let chTitle = ch["title"] as? String else { continue }
let paragraphsES = (ch["paragraphsES"] as? [String]) ?? []
let paragraphsEN = (ch["paragraphsEN"] as? [String]) ?? []
let esData = (try? JSONEncoder().encode(paragraphsES)) ?? Data()
let enData = (try? JSONEncoder().encode(paragraphsEN)) ?? Data()
let chapter = BookChapter(
id: "\(slug)-ch\(number)",
bookSlug: slug,
number: number,
title: chTitle,
paragraphCount: paragraphsES.count,
paragraphsESJSON: esData,
paragraphsENJSON: enData
)
context.insert(chapter)
}
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: book save failed: \(error)")
return false
}
let persistedBooks = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
let persistedChapters = (try? context.fetchCount(FetchDescriptor<BookChapter>())) ?? 0
guard persistedBooks > 0 else {
print("[DataLoader] ERROR: seeded \(insertedBooks) books but persisted count is 0")
return false
}
print("Book seeding complete: \(persistedBooks) books, \(persistedChapters) chapters")
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
/// don't depend on `Bundle.urls(forResourcesWithExtension:subdirectory:)`
/// successfully enumerating the bundle that API has been observed to
/// return empty for some iOS configurations even when the resource is
/// present, matching the same `bundleURL.appendingPathComponent` fallback
/// used by the textbook seed.
private static let bundledBookSlugs: [String] = [
"olly-vol2",
]
/// Resolve URLs for every bundled book. Uses the explicit-slug fast path
/// first (mirrors `seedTextbookData`'s lookup pattern), then falls back to
/// directory enumeration so newly-bundled books are picked up without a
/// code change.
private static func bundledBookJSONURLs() -> [URL] {
var seen = Set<String>()
var out: [URL] = []
let bundle = Bundle.main
for slug in bundledBookSlugs {
let filename = "book_\(slug).json"
let url = bundle.url(forResource: "book_\(slug)", withExtension: "json")
?? bundle.bundleURL.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: url.path),
seen.insert(filename).inserted {
out.append(url)
}
}
if let urls = bundle.urls(forResourcesWithExtension: "json", subdirectory: nil) {
for url in urls where url.lastPathComponent.hasPrefix("book_") {
if seen.insert(url.lastPathComponent).inserted {
out.append(url)
}
}
}
let names = out.map(\.lastPathComponent).joined(separator: ", ")
print("[DataLoader] bundledBookJSONURLs found \(out.count) files: [\(names)]")
return out.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
/// Deterministic accent colour for a book, derived from its slug so the
/// cover tile has a stable colour across launches.
private static func accentHex(forSlug slug: String) -> String {
let palette = [
"#7B6CF6", "#E07A5F", "#3D5A80", "#81B29A",
"#F2CC8F", "#D4A5A5", "#5B8A72", "#A06CD5",
]
let hash = slug.unicodeScalars.reduce(0) { ($0 &* 31) &+ Int($1.value) }
return palette[abs(hash) % palette.count]
}
private static func seedTextbookVocabDecks(context: ModelContext, courseName: String) {
let url = Bundle.main.url(forResource: "textbook_vocab", withExtension: "json")
?? Bundle.main.bundleURL.appendingPathComponent("textbook_vocab.json")
guard let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let chaptersArr = json["chapters"] as? [[String: Any]]
else { return }
let courseSlug = courseName.lowercased()
.replacingOccurrences(of: " ", with: "-")
var deckCount = 0
var cardCount = 0
for chData in chaptersArr {
guard let chNum = chData["chapter"] as? Int,
let cards = chData["cards"] as? [[String: Any]],
!cards.isEmpty else { continue }
let deckId = "textbook_\(courseSlug)_ch\(chNum)"
let title = "Chapter \(chNum) vocabulary"
let deck = CourseDeck(
id: deckId,
weekNumber: chNum,
title: title,
cardCount: cards.count,
courseName: courseName,
isReversed: false
)
context.insert(deck)
deckCount += 1
for c in cards {
guard let front = c["front"] as? String,
let back = c["back"] as? String else { continue }
let card = VocabCard(front: front, back: back, deckId: deckId)
card.deck = deck
context.insert(card)
cardCount += 1
}
}
try? context.save()
print("Textbook vocab seeding complete: \(deckCount) decks, \(cardCount) cards")
}
}
@@ -0,0 +1,88 @@
import Foundation
import SharedModels
import SwiftData
/// Cloud-context CRUD for `ExtraStudyMark`. Uniqueness is enforced in code via
/// fetch-or-create on `id` (CloudKit forbids `@Attribute(.unique)`).
struct ExtraStudyStore {
let context: ModelContext
private func fetchMark(id: String) -> ExtraStudyMark? {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> { $0.id == id }
)
return (try? context.fetch(descriptor))?.first
}
func contains(card: VocabCard) -> Bool {
fetchMark(id: CourseCardStore.reviewKey(for: card)) != nil
}
/// Toggle a mark for the given card. Returns the new "is marked" state.
@discardableResult
func toggle(
card: VocabCard,
courseName: String,
weekNumber: Int
) -> Bool {
let id = CourseCardStore.reviewKey(for: card)
if let existing = fetchMark(id: id) {
context.delete(existing)
try? context.save()
return false
}
let mark = ExtraStudyMark(
id: id,
deckId: card.deckId,
courseName: courseName,
weekNumber: weekNumber,
front: card.front,
back: card.back
)
context.insert(mark)
try? context.save()
return true
}
func count(courseName: String, weekNumber: Int) -> Int {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> {
$0.courseName == courseName && $0.weekNumber == weekNumber
}
)
return (try? context.fetchCount(descriptor)) ?? 0
}
func countsByWeek(courseName: String) -> [Int: Int] {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> { $0.courseName == courseName }
)
let marks = (try? context.fetch(descriptor)) ?? []
var counts: [Int: Int] = [:]
for mark in marks {
counts[mark.weekNumber, default: 0] += 1
}
return counts
}
func fetch(courseName: String, weekNumber: Int) -> [ExtraStudyMark] {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> {
$0.courseName == courseName && $0.weekNumber == weekNumber
},
sortBy: [SortDescriptor(\.markedAt)]
)
return (try? context.fetch(descriptor)) ?? []
}
func fetchIds(courseName: String, weekNumber: Int) -> Set<String> {
Set(fetch(courseName: courseName, weekNumber: weekNumber).map(\.id))
}
}
/// Lightweight context passed to `VocabFlashcardView` so the in-session star
/// button knows which week/course to attribute a mark to.
struct ExtraStudyMarkContext: Equatable {
let courseName: String
let weekNumber: Int
}
@@ -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,217 @@
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
}
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
}
let ordered = due.map(\.lexeme) + fresh
return Array(ordered.prefix(sessionCardLimit(for: partOfSpeech)))
}
/// 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)) ?? []
}
}
@@ -4,14 +4,23 @@ import SwiftData
struct PracticeSettings: Sendable {
let selectedLevel: String
let selectedLevels: Set<String>
let enabledTenses: Set<String>
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
let showVosotros: Bool
let showReflexiveVerbsOnly: Bool
let reflexiveBaseInfinitives: Set<String>
init(progress: UserProgress?) {
let resolved = progress?.enabledTenseIDs ?? []
init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
let resolvedTenses = progress?.enabledTenseIDs ?? []
let resolvedLevels = progress?.selectedVerbLevels ?? []
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
self.enabledTenses = Set(resolved)
self.selectedLevels = Set(resolvedLevels.map(\.rawValue))
self.enabledTenses = Set(resolvedTenses)
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
self.showVosotros = progress?.showVosotros ?? true
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
}
var selectionTenseIDs: [String] {
@@ -36,16 +45,25 @@ struct FullTablePrompt {
struct PracticeSessionService {
let localContext: ModelContext
let cloudContext: ModelContext
let reflexiveBaseInfinitives: Set<String>
private let referenceStore: ReferenceStore
init(localContext: ModelContext, cloudContext: ModelContext) {
init(
localContext: ModelContext,
cloudContext: ModelContext,
reflexiveBaseInfinitives: Set<String> = []
) {
self.localContext = localContext
self.cloudContext = cloudContext
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
self.referenceStore = ReferenceStore(context: localContext)
}
func settings() -> PracticeSettings {
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
PracticeSettings(
progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext),
reflexiveBaseInfinitives: reflexiveBaseInfinitives
)
}
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
@@ -77,24 +95,114 @@ struct PracticeSessionService {
return nil
}
func randomFullTablePrompt() -> FullTablePrompt? {
/// Builds a Full Table prompt. `previousTenseId` / `previousEnding` describe
/// the prompt just shown; when possible the next prompt avoids repeating the
/// same tense back-to-back and switches the verb's ending family
/// (-ar -er/-ir) so consecutive rounds feel varied. Both are best-effort:
/// if honouring them would leave no eligible combo, the constraint is
/// dropped rather than dead-ending.
func randomFullTablePrompt(
previousTenseId: String? = nil,
previousEnding: String? = nil
) -> FullTablePrompt? {
let settings = settings()
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
// Full Table is testing the user's grasp of regular conjugation patterns,
// not vocabulary recognition. Level filter is intentionally bypassed so
// we draw from the entire verb pool being able to conjugate `hablar`
// regularly transfers to any other regular verb regardless of "level".
// Irregular-category and tense filters still apply via downstream checks.
let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(),
settings: settings
)
guard !verbs.isEmpty else { return nil }
for _ in 0..<40 {
guard let verb = verbs.randomElement(),
let tenseId = settings.selectionTenseIDs.randomElement(),
let tenseInfo = TenseInfo.find(tenseId) else { continue }
let candidateTenseIds = settings.selectionTenseIDs
guard !candidateTenseIds.isEmpty else { return nil }
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
if forms.isEmpty { continue }
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms)
let tenseChoices = tenseChoicesAvoidingRepeat(candidateTenseIds, previous: previousTenseId)
let verbChoices = verbChoicesSwitchingFamily(verbs, previousEnding: previousEnding)
let isConstrained = tenseChoices.count != candidateTenseIds.count
|| verbChoices.count != verbs.count
// Best-effort: random-sample the constrained pool first so consecutive
// prompts vary the tense and the -ar/-er-ir family.
if isConstrained,
let prompt = sampleFullTablePrompt(verbs: verbChoices, tenseIds: tenseChoices) {
return prompt
}
// No constraint, or honouring it found nothing quickly sample the
// full pool.
if let prompt = sampleFullTablePrompt(verbs: verbs, tenseIds: candidateTenseIds) {
return prompt
}
// Guarantee: if any eligible (verb, tense) combo exists in the data we
// return one. Only return nil when the user's settings genuinely produce
// an empty pool (so the UI can show an error state instead of a blank).
for verb in verbs.shuffled() {
for tenseId in candidateTenseIds.shuffled() {
guard let tenseInfo = TenseInfo.find(tenseId) else { continue }
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
return prompt
}
}
}
return nil
}
/// Random-sample up to 40 (verb, tense) pairs for a fully-regular combo.
/// With ~1750 verbs and several hundred eligible combos this almost always
/// succeeds within a handful of attempts.
private func sampleFullTablePrompt(verbs: [Verb], tenseIds: [String]) -> FullTablePrompt? {
guard !verbs.isEmpty, !tenseIds.isEmpty else { return nil }
for _ in 0..<40 {
guard let verb = verbs.randomElement(),
let tenseId = tenseIds.randomElement(),
let tenseInfo = TenseInfo.find(tenseId) else { continue }
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
return prompt
}
}
return nil
}
/// Tense ids with the previous one removed only when more than one tense
/// is selected and removing it still leaves a choice.
private func tenseChoicesAvoidingRepeat(_ ids: [String], previous: String?) -> [String] {
guard ids.count > 1, let previous else { return ids }
let filtered = ids.filter { $0 != previous }
return filtered.isEmpty ? ids : filtered
}
/// Verbs whose ending family (-ar vs -er/-ir) differs from the previous
/// verb's only when both families are actually present in the pool.
private func verbChoicesSwitchingFamily(_ verbs: [Verb], previousEnding: String?) -> [Verb] {
guard let previousEnding else { return verbs }
let previousIsAr = (previousEnding == "ar")
let switched = verbs.filter { ($0.ending == "ar") != previousIsAr }
return switched.isEmpty ? verbs : switched
}
/// Returns a `FullTablePrompt` if this verb's forms in the given tense
/// follow the regular pattern (per `FullTableEligibility`). Nil otherwise.
private func makePromptIfFullyRegular(
verb: Verb,
tenseId: String,
tenseInfo: TenseInfo
) -> FullTablePrompt? {
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
guard !forms.isEmpty else { return nil }
// Forms must arrive in personIndex order so the regularity array lines
// up. `fetchForms` already sorts them, but assert for safety.
let sorted = forms.sorted { $0.personIndex < $1.personIndex }
let regularities = sorted.map { $0.regularity }
guard FullTableEligibility.isFullyRegular(regularities: regularities) else { return nil }
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: sorted)
}
func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
ReviewStore.recordReview(
verbId: verbId,
@@ -131,6 +239,27 @@ struct PracticeSessionService {
return buildCardLoad(verb: verb, form: form)
}
/// When the user has "Reflexive verbs only" enabled, restrict the allowed
/// verb-id set to IDs whose infinitive is in the curated list.
/// No-op otherwise.
private func applyReflexiveFilter(to ids: Set<Int>, settings: PracticeSettings) -> Set<Int> {
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
return ids
}
let matching = ids.filter { id in
guard let verb = referenceStore.fetchVerb(id: id) else { return false }
return settings.reflexiveBaseInfinitives.contains(verb.infinitive.lowercased())
}
return matching
}
private func applyReflexiveFilter(to verbs: [Verb], settings: PracticeSettings) -> [Verb] {
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
return verbs
}
return verbs.filter { settings.reflexiveBaseInfinitives.contains($0.infinitive.lowercased()) }
}
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
let spans = referenceStore.fetchSpans(
verbId: form.verbId,
@@ -152,7 +281,13 @@ struct PracticeSessionService {
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
let now = Date()
var descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
@@ -179,7 +314,13 @@ struct PracticeSessionService {
private func pickWeakForm() -> VerbForm? {
let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
let descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
@@ -201,7 +342,15 @@ struct PracticeSessionService {
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
// Focus mode explicitly selects one irregular category, so the user's
// settings-level irregular filter is deliberately skipped here.
let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: []
),
settings: settings
)
let typeRange: ClosedRange<Int>
switch filter {
@@ -238,7 +387,13 @@ struct PracticeSessionService {
private func pickCommonTenseForm() -> VerbForm? {
let settings = settings()
let coreTenseIDs = TenseID.coreTenseIDs
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
@@ -251,7 +406,13 @@ struct PracticeSessionService {
private func pickRandomForm() -> VerbForm? {
let settings = settings()
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
@@ -65,28 +65,26 @@ final class PronunciationService {
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers])
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
audioEngine = AVAudioEngine()
// Use SFSpeechAudioBufferRecognitionRequest with the recognizer
// directly avoid AVAudioEngine entirely since it produces
// zero-length buffers on some devices causing assertion crashes.
request = SFSpeechAudioBufferRecognitionRequest()
guard let audioEngine, let request else { return }
guard let request else { return }
request.shouldReportPartialResults = true
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
// Use AVAudioEngine with the native input format
audioEngine = AVAudioEngine()
guard let audioEngine else { return }
let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
// Validate format 0 channels crashes installTap
guard recordingFormat.channelCount > 0 else {
print("[PronunciationService] invalid recording format (0 channels)")
self.audioEngine = nil
self.request = nil
return
}
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
guard buffer.frameLength > 0 else { return }
// Use nil format lets the system pick a compatible format
// and avoids the mDataByteSize(0) assertion from format mismatches
inputNode.installTap(onBus: 0, bufferSize: 4096, format: nil) { buffer, _ in
request.append(buffer)
}
@@ -27,6 +27,50 @@ struct ReferenceStore {
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
}
/// Union of data-levels for all selected user-facing levels.
/// Empty input produces an empty result callers decide how to handle that.
func fetchVerbs(selectedLevels: Set<String>) -> [Verb] {
guard !selectedLevels.isEmpty else { return [] }
let ids = PracticeFilter.verbIDs(
matchingLevels: selectedLevels,
in: fetchVerbs().map { .init(id: $0.id, level: $0.level) }
)
return fetchVerbs().filter { ids.contains($0.id) }
}
/// Practice verb pool intersecting selected levels with selected irregular-span categories.
/// Delegates to `PracticeFilter` so the intersection logic is unit-tested
/// in SharedModels without a ModelContainer (Issue #26).
func allowedVerbIDs(
selectedLevels: Set<String>,
irregularCategories: Set<IrregularSpan.SpanCategory>
) -> Set<Int> {
PracticeFilter.allowedVerbIDs(
verbs: fetchVerbs().map { .init(id: $0.id, level: $0.level) },
spans: allIrregularSlots(),
selectedLevels: selectedLevels,
irregularCategories: irregularCategories
)
}
/// Convenience: full Verb objects passing both filters.
func fetchVerbs(
selectedLevels: Set<String>,
irregularCategories: Set<IrregularSpan.SpanCategory>
) -> [Verb] {
let ids = allowedVerbIDs(
selectedLevels: selectedLevels,
irregularCategories: irregularCategories
)
return fetchVerbs().filter { ids.contains($0.id) }
}
private func allIrregularSlots() -> [PracticeFilter.IrregularSlot] {
let descriptor = FetchDescriptor<IrregularSpan>()
let spans = (try? context.fetch(descriptor)) ?? []
return spans.map { .init(verbId: $0.verbId, category: $0.category) }
}
func fetchVerb(id: Int) -> Verb? {
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
return (try? context.fetch(descriptor))?.first
@@ -50,6 +94,19 @@ struct ReferenceStore {
return (try? context.fetch(descriptor)) ?? []
}
/// Map of tenseId conjugated forms for a verb, used to ground and
/// validate LLM-generated example sentences.
func conjugatedForms(verbId: Int, tenseIds: [String]) -> [String: [String]] {
var map: [String: [String]] = [:]
for tenseId in tenseIds {
let forms = fetchForms(verbId: verbId, tenseId: tenseId)
.map(\.form)
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
if !forms.isEmpty { map[tenseId] = forms }
}
return map
}
func fetchForm(verbId: Int, tenseId: String, personIndex: Int) -> VerbForm? {
let descriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { form in
@@ -0,0 +1,59 @@
import Foundation
import SharedModels
/// Loads and queries the curated reflexive-verb list bundled with the app
/// (Gitea issue #28). One JSON load at init; in-memory lookup thereafter.
///
/// `entries(for:)` returns a list because a single base infinitive may map to
/// multiple reflexive entries e.g., `ponerse` covers both "to put on
/// (clothing) / to become" and "to come to an agreement (with)".
@MainActor
@Observable
final class ReflexiveVerbStore {
/// Process-wide accessor for services that can't use @Environment injection
/// (e.g. PracticeSessionService called from ViewModels). Views should still
/// prefer @Environment(ReflexiveVerbStore.self) for consistency.
static let shared = ReflexiveVerbStore()
private(set) var entries: [ReflexiveVerb] = []
private var indexByBase: [String: [ReflexiveVerb]] = [:]
/// Set of base infinitives present in the list. Cheap lookup for filters.
private(set) var baseInfinitives: Set<String> = []
init(bundle: Bundle = .main) {
load(from: bundle)
}
/// All reflexive entries whose base infinitive matches (case-insensitive).
func entries(for baseInfinitive: String) -> [ReflexiveVerb] {
indexByBase[baseInfinitive.lowercased()] ?? []
}
/// Convenience true when the verb's bare infinitive appears in the list.
func isReflexive(baseInfinitive: String) -> Bool {
baseInfinitives.contains(baseInfinitive.lowercased())
}
private func load(from bundle: Bundle) {
guard let url = bundle.url(forResource: "reflexive_verbs", withExtension: "json"),
let data = try? Data(contentsOf: url) else {
print("[ReflexiveVerbStore] bundled reflexive_verbs.json not found")
return
}
do {
let decoded = try JSONDecoder().decode([ReflexiveVerb].self, from: data)
entries = decoded
var index: [String: [ReflexiveVerb]] = [:]
for entry in decoded {
index[entry.baseInfinitive.lowercased(), default: []].append(entry)
}
indexByBase = index
baseInfinitives = Set(index.keys)
print("[ReflexiveVerbStore] loaded \(decoded.count) entries (\(baseInfinitives.count) distinct base infinitives)")
} catch {
print("[ReflexiveVerbStore] decode failed: \(error)")
}
}
}
+23 -7
View File
@@ -72,13 +72,13 @@ struct ReviewStore {
return newCard
}
/// Bumps the streak / "showed up today" bookkeeping without touching
/// review-specific counters. Call from any user-initiated learning action
/// sending a chat message, doing an exercise, watching a curated video,
/// looking up a word in lyrics, etc. Safe to call multiple times per day;
/// only the first call on a fresh date moves the streak.
@discardableResult
static func updateProgress(
reviewIncrement: Int,
correctIncrement: Int,
context: ModelContext,
date: Date = Date()
) -> UserProgress {
static func recordActivity(context: ModelContext, date: Date = Date()) -> UserProgress {
let progress = fetchOrCreateUserProgress(context: context)
let todayString = DailyLog.dateString(from: date)
@@ -97,9 +97,25 @@ struct ReviewStore {
progress.todayCount = 0
}
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
try? context.save()
return progress
}
@discardableResult
static func updateProgress(
reviewIncrement: Int,
correctIncrement: Int,
context: ModelContext,
date: Date = Date()
) -> UserProgress {
// Bump streak / today-date first so review-specific counters land on
// the correct day if this is the user's first action after midnight.
let progress = recordActivity(context: context, date: date)
let todayString = DailyLog.dateString(from: date)
progress.todayCount += reviewIncrement
progress.totalReviewed += reviewIncrement
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
log.reviewCount += reviewIncrement
@@ -9,6 +9,9 @@ enum StartupCoordinator {
static func bootstrap(localContainer: ModelContainer) async {
await DataLoader.seedIfNeeded(container: localContainer)
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
await DataLoader.refreshBooksDataIfNeeded(container: localContainer)
await DataLoader.refreshLexemesIfNeeded(container: localContainer)
}
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
@@ -26,12 +26,14 @@ enum StoreInspector {
let hasZVERBFORM = tables.contains("ZVERBFORM")
let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE")
let hasZVOCABCARD = tables.contains("ZVOCABCARD")
let hasZTEXTBOOKCHAPTER = tables.contains("ZTEXTBOOKCHAPTER")
var summary = "[StoreInspector:\(label)] \(tables.count) tables"
summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)"
summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)"
summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)"
summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)"
summary += " ZTEXTBOOKCHAPTER=\(hasZTEXTBOOKCHAPTER ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTEXTBOOKCHAPTER") : -1)"
print(summary)
// Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables)
@@ -0,0 +1,81 @@
import Foundation
import SharedModels
/// Disk-backed cache for verb example sentences (Issue #27). One JSON file
/// in the Caches directory keyed by verb id; lazy-loaded on first access and
/// write-through on every generation. Matches DictionaryService's disk pattern.
///
/// Cache eviction by the OS is acceptable because contents are regenerable.
@MainActor
@Observable
final class VerbExampleCache {
/// Bump to invalidate every cached example set. Raised to 2 for Issue #33
/// examples generated before the verb-grounding/validation fix could
/// contain sentences built on the wrong verb.
private static let cacheVersion = 2
private struct CacheFile: Codable {
var version: Int
var entries: [String: [VerbExample]]
}
private var store: [Int: [VerbExample]] = [:]
private var isLoaded = false
init() {}
// MARK: - Public API
/// Look up cached examples for a verb; returns nil on miss.
/// Safe to call before `loadIfNeeded()`; it triggers the disk load itself.
func examples(for verbId: Int) -> [VerbExample]? {
loadIfNeeded()
return store[verbId]
}
/// Store newly generated examples and persist to disk.
func setExamples(_ examples: [VerbExample], for verbId: Int) {
loadIfNeeded()
store[verbId] = examples
save()
}
// MARK: - Disk I/O
private static var cacheURL: URL {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("verb_examples.json")
}
private func loadIfNeeded() {
guard !isLoaded else { return }
defer { isLoaded = true }
guard let data = try? Data(contentsOf: Self.cacheURL),
let decoded = try? JSONDecoder().decode(CacheFile.self, from: data),
decoded.version == Self.cacheVersion
else {
// Missing, unreadable, old flat format, or stale version start
// fresh so pre-fix examples don't linger.
return
}
// Persisted with String keys because JSON object keys are strings;
// convert back to Int for in-memory lookup.
var rebuilt: [Int: [VerbExample]] = [:]
for (key, value) in decoded.entries {
if let id = Int(key) {
rebuilt[id] = value
}
}
store = rebuilt
}
private func save() {
let entries = Dictionary(uniqueKeysWithValues: store.map { (String($0.key), $0.value) })
let file = CacheFile(version: Self.cacheVersion, entries: entries)
guard let data = try? JSONEncoder().encode(file) else { return }
try? data.write(to: Self.cacheURL)
}
}
@@ -0,0 +1,172 @@
import Foundation
import FoundationModels
import SharedModels
/// Generates a set of example sentences for a single verb, one per core tense
/// (Issue #27). Mirrors the StoryGenerator pattern: @Generable response types,
/// a static availability flag, and a single generate(...) entry point.
///
/// Issue #33: the model used to drift onto other verbs partway through the
/// 6-example batch (a "tener" set would contain sentences built on estar / ir
/// / deber). Two defenses now apply:
/// 1. The prompt embeds the verb's *exact* conjugated forms per tense, so
/// the model echoes a real form instead of recalling one.
/// 2. Every generated sentence is validated against those forms; failures
/// are regenerated once, and anything still wrong is dropped rather than
/// shown.
@MainActor
struct VerbExampleGenerator {
// MARK: - Generable Types
@Generable
struct GeneratedExampleSet {
@Guide(
description: "Six example sentences, one per tense in the exact order requested. Each sentence must actually use the target verb conjugated in that tense.",
.count(6)
)
var examples: [GeneratedExample]
}
@Generable
struct GeneratedExample {
@Guide(description: "The tense id this sentence demonstrates. Must match one of the ids provided in the prompt exactly (e.g. ind_presente).")
var tenseId: String
@Guide(description: "A natural Spanish sentence, 6-14 words, that uses the target verb in the specified tense. For imperative tenses use tú or nosotros — never yo.")
var spanish: String
@Guide(description: "An accurate, idiomatic English translation of the Spanish sentence.")
var english: String
}
// MARK: - Generation
/// Generate one validated example per tense in `tenseIds`.
///
/// - Parameter formsByTense: the verb's conjugated forms keyed by tenseId
/// (from `ReferenceStore.conjugatedForms`). Used to ground the prompt
/// and validate the output. A tense with no forms here is accepted
/// without validation.
static func generate(
verbInfinitive: String,
verbEnglish: String,
tenseIds: [String],
formsByTense: [String: [String]]
) async throws -> [VerbExample] {
// A thrown error here (model busy, context overflow) shouldn't abort the
// whole generation treat it as a fully-failed batch so the retry below
// still runs, same as the retry path's own `try?`.
let firstPass = (try? await generateBatch(
verbInfinitive: verbInfinitive,
verbEnglish: verbEnglish,
tenseIds: tenseIds,
formsByTense: formsByTense
)) ?? [:]
var valid: [String: VerbExample] = [:]
var failedTenses: [String] = []
for id in tenseIds {
if let ex = firstPass[id], exampleUsesVerb(ex.spanish, forms: formsByTense[id] ?? []) {
valid[id] = ex
} else {
failedTenses.append(id)
}
}
// One focused retry regenerate the whole batch, but only adopt the
// results for tenses that failed the first pass.
if !failedTenses.isEmpty {
let retry = try? await generateBatch(
verbInfinitive: verbInfinitive,
verbEnglish: verbEnglish,
tenseIds: tenseIds,
formsByTense: formsByTense
)
if let retry {
for id in failedTenses {
if let ex = retry[id], exampleUsesVerb(ex.spanish, forms: formsByTense[id] ?? []) {
valid[id] = ex
}
}
}
}
// Requested order, dropping any tense that never produced a valid
// sentence better to show fewer examples than wrong ones.
return tenseIds.compactMap { valid[$0] }
}
// MARK: - Single batch call
private static func generateBatch(
verbInfinitive: String,
verbEnglish: String,
tenseIds: [String],
formsByTense: [String: [String]]
) async throws -> [String: VerbExample] {
let tenseBlock = tenseIds.compactMap { id -> String? in
guard let info = TenseInfo.find(id) else { return nil }
let forms = formsByTense[id] ?? []
if forms.isEmpty {
return "- \(id) (\(info.english))"
}
return "- \(id) (\(info.english)): use one of these exact conjugated forms — \(forms.joined(separator: ", "))"
}.joined(separator: "\n")
let session = LanguageModelSession(instructions: """
You are a Spanish language teacher writing short example sentences for a learner.
The learner is studying the verb "\(verbInfinitive)" (to \(verbEnglish)).
EVERY sentence must use "\(verbInfinitive)" as its main verb, conjugated in the
requested tense — never substitute a different verb (no estar, ir, deber, etc.
unless the target verb itself is that verb). Each sentence must:
- Contain one of the exact conjugated forms listed for its tense.
- Be 6-14 words, natural and everyday.
- Use vocabulary appropriate for intermediate learners.
- Vary subjects and contexts across the set; do not reuse the same subject twice.
For imperative tenses, address "" or "nosotros" — never "yo".
""")
let prompt = """
Write one example sentence for "\(verbInfinitive)" per tense below, in this order:
\(tenseBlock)
Return one GeneratedExample per tense with the matching tenseId, spanish, and english.
The Spanish sentence MUST contain one of the conjugated forms shown for that tense.
"""
let response = try await session.respond(to: prompt, generating: GeneratedExampleSet.self)
// `uniquingKeysWith` defensively the schema forces 6 examples even
// when fewer tenses are requested, so the model may repeat a tenseId.
return Dictionary(
response.content.examples.map {
($0.tenseId, VerbExample(tenseId: $0.tenseId, spanish: $0.spanish, english: $0.english))
},
uniquingKeysWith: { first, _ in first }
)
}
// MARK: - Validation
/// True when `sentence` contains at least one of `forms` as a whole word
/// (accent- and case-insensitive). Empty `forms` accept (can't validate).
static func exampleUsesVerb(_ sentence: String, forms: [String]) -> Bool {
guard !forms.isEmpty else { return true }
let sentenceWords = foldedWords(sentence)
let formWords = Set(forms.flatMap { foldedWords($0) })
return !sentenceWords.isDisjoint(with: formWords)
}
private static func foldedWords(_ text: String) -> Set<String> {
let folded = text.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: nil
)
return Set(folded.split { !$0.isLetter }.map(String.init))
}
static var isAvailable: Bool {
SystemLanguageModel.default.availability == .available
}
}
@@ -0,0 +1,39 @@
import Foundation
import SharedModels
import SwiftData
/// SRS rating for verb-level vocab practice. Mirrors `CourseReviewStore` but
/// keyed by `verbId` (the integer primary key on `Verb`).
struct VerbReviewStore {
let context: ModelContext
@discardableResult
func fetchOrCreateReviewCard(verbId: Int) -> VerbReviewCard {
let id = VerbReviewCard.makeId(verbId: verbId)
let descriptor = FetchDescriptor<VerbReviewCard>(
predicate: #Predicate<VerbReviewCard> { $0.id == id }
)
if let existing = (try? context.fetch(descriptor))?.first {
return existing
}
let card = VerbReviewCard(verbId: verbId)
context.insert(card)
return card
}
func rate(verbId: Int, quality: ReviewQuality) {
let card = fetchOrCreateReviewCard(verbId: verbId)
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,363 @@
import Foundation
import SwiftData
import SharedModels
import AVFoundation
import YouTubeKit
/// Downloads YouTube videos for offline viewing (Issue #21, updated for #30).
///
/// Two-path strategy since YouTube phased out high-quality progressive streams:
/// 1. **Progressive** single MP4 with audio+video combined (rare, 360p).
/// Download directly to the final path.
/// 2. **Adaptive** DASH: separate video + audio tracks. Download each to
/// temp files, then mux with AVAssetExportSession (`.passthrough` preset,
/// no re-encoding) into the final MP4.
///
/// YouTubeKit returns stream URLs only; combining tracks is the app's job.
/// See the library's README Example 3 for the progressive-only pattern.
///
/// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and
/// will break when YouTube changes their internal format. Streaming (iframe
/// embed elsewhere) keeps working regardless.
@MainActor
@Observable
final class VideoDownloadService {
// MARK: - Types
/// Per-download state surfaced to the UI. `progress == nil` means show an
/// indeterminate spinner (e.g. muxing phase).
struct DownloadStatus: Equatable, Sendable {
var progress: Double?
var label: String
}
enum DownloadError: Error, LocalizedError {
case extractionFailed(String)
case noSuitableStream
case downloadFailed(String)
case muxFailed(String)
case fileWriteFailed(String)
var errorDescription: String? {
switch self {
case .extractionFailed(let why): "Could not extract video: \(why)"
case .noSuitableStream: "No downloadable video+audio streams found for this video."
case .downloadFailed(let why): "Download failed: \(why)"
case .muxFailed(let why): "Could not combine audio and video: \(why)"
case .fileWriteFailed(let why): "Could not save video: \(why)"
}
}
}
/// In-flight downloads by videoId, with a phase label and progress.
var activeDownloads: [String: DownloadStatus] = [:]
static let shared = VideoDownloadService()
// MARK: - Paths
private static var videosDirectory: URL {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return docs.appendingPathComponent("videos", isDirectory: true)
}
private static var tempDirectory: URL {
FileManager.default.temporaryDirectory
}
private static func ensureDirectory() throws {
let url = videosDirectory
if !FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
}
}
static func fileURL(for videoId: String) -> URL {
videosDirectory.appendingPathComponent("\(videoId).mp4")
}
private static func tempURL(videoId: String, kind: String, ext: String) -> URL {
tempDirectory.appendingPathComponent("\(videoId).\(kind).\(ext)")
}
/// True if a downloaded MP4 exists for this videoId.
static func isDownloaded(videoId: String) -> Bool {
FileManager.default.fileExists(atPath: fileURL(for: videoId).path)
}
// MARK: - Public download entry point
func download(
videoId: String,
title: String,
into modelContext: ModelContext
) async throws {
guard activeDownloads[videoId] == nil else { return }
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Preparing…")
defer { activeDownloads.removeValue(forKey: videoId) }
try Self.ensureDirectory()
let destURL = Self.fileURL(for: videoId)
// Resolve streams once; decide progressive vs adaptive.
let plan: DownloadPlan
do {
plan = try await Self.resolvePlan(videoId: videoId)
} catch let e as DownloadError {
throw e
} catch {
throw DownloadError.extractionFailed(error.localizedDescription)
}
switch plan {
case .progressive(let url):
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Downloading…")
try await downloadStream(from: url, to: destURL) { [weak self] p in
Task { @MainActor [weak self] in
self?.activeDownloads[videoId] = DownloadStatus(
progress: p,
label: "Downloading \(Int(p * 100))%"
)
}
}
case .adaptive(let videoURL, let audioURL):
let tempVideo = Self.tempURL(videoId: videoId, kind: "video", ext: "mp4")
let tempAudio = Self.tempURL(videoId: videoId, kind: "audio", ext: "m4a")
defer {
try? FileManager.default.removeItem(at: tempVideo)
try? FileManager.default.removeItem(at: tempAudio)
}
// Phase 1 of 3: video track (055% of overall progress)
try await downloadStream(from: videoURL, to: tempVideo) { [weak self] p in
Task { @MainActor [weak self] in
self?.activeDownloads[videoId] = DownloadStatus(
progress: p * 0.55,
label: "Video \(Int(p * 100))%"
)
}
}
// Phase 2 of 3: audio track (5580%)
try await downloadStream(from: audioURL, to: tempAudio) { [weak self] p in
Task { @MainActor [weak self] in
self?.activeDownloads[videoId] = DownloadStatus(
progress: 0.55 + p * 0.25,
label: "Audio \(Int(p * 100))%"
)
}
}
// Phase 3 of 3: mux (indeterminate)
activeDownloads[videoId] = DownloadStatus(progress: nil, label: "Finalizing…")
do {
try await Self.mux(videoURL: tempVideo, audioURL: tempAudio, to: destURL)
} catch let e as DownloadError {
throw e
} catch {
throw DownloadError.muxFailed(error.localizedDescription)
}
}
// Record in SwiftData.
let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path)
let byteCount = (attrs?[.size] as? Int) ?? 0
let entry = DownloadedVideo(
videoId: videoId,
title: title,
filename: "\(videoId).mp4",
byteCount: byteCount
)
modelContext.insert(entry)
try? modelContext.save()
}
/// Deletes the downloaded file and its SwiftData row.
func delete(videoId: String, modelContext: ModelContext) {
let url = Self.fileURL(for: videoId)
try? FileManager.default.removeItem(at: url)
let descriptor = FetchDescriptor<DownloadedVideo>(
predicate: #Predicate<DownloadedVideo> { $0.videoId == videoId }
)
if let existing = try? modelContext.fetch(descriptor) {
for entry in existing {
modelContext.delete(entry)
}
try? modelContext.save()
}
}
/// Total bytes used by all downloads.
static func totalBytesUsed() -> Int {
let url = videosDirectory
guard let contents = try? FileManager.default.contentsOfDirectory(
at: url, includingPropertiesForKeys: [.fileSizeKey]
) else { return 0 }
return contents.reduce(0) { acc, file in
let size = (try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
return acc + size
}
}
// MARK: - Stream resolution
private enum DownloadPlan {
case progressive(URL)
case adaptive(video: URL, audio: URL)
}
/// Picks progressive MP4 if available (single download); otherwise the
/// best adaptive MP4 video + M4A audio pair for later muxing.
nonisolated private static func resolvePlan(videoId: String) async throws -> DownloadPlan {
let youtube = YouTube(videoID: videoId)
let streams = try await youtube.streams
// 1. Progressive path uses the library's own recommended filter chain.
if let progressive = streams
.filterVideoAndAudio()
.filter({ $0.fileExtension == .mp4 && $0.isNativelyPlayable })
.highestResolutionStream() {
return .progressive(progressive.url)
}
// 2. Adaptive path highest-resolution MP4 video track + highest-bitrate M4A audio.
let videoStream = streams
.filterVideoOnly()
.filter { $0.fileExtension == .mp4 && $0.isNativelyPlayable }
.highestResolutionStream()
let audioStream = streams
.filterAudioOnly()
.filter {
($0.fileExtension == .m4a || $0.fileExtension == .mp4) && $0.isNativelyPlayable
}
.highestAudioBitrateStream()
if let v = videoStream, let a = audioStream {
return .adaptive(video: v.url, audio: a.url)
}
throw DownloadError.noSuitableStream
}
// MARK: - Download helper
nonisolated private func downloadStream(
from url: URL,
to dest: URL,
onProgress: @escaping @Sendable (Double) -> Void
) async throws {
let delegate = DownloadProgressDelegate(onProgress: onProgress)
do {
let (tempURL, _) = try await URLSession.shared.download(
for: URLRequest(url: url),
delegate: delegate
)
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.moveItem(at: tempURL, to: dest)
} catch {
throw DownloadError.downloadFailed(error.localizedDescription)
}
}
// MARK: - Muxing
/// Combines a video-only file and an audio-only file into a single MP4.
/// Uses `.passthrough` preset no re-encoding, just container rewrite.
/// Falls back to `.highestQuality` (which re-encodes) if passthrough
/// rejects the composition due to codec incompatibility.
nonisolated private static func mux(
videoURL: URL,
audioURL: URL,
to outputURL: URL
) async throws {
let videoAsset = AVURLAsset(url: videoURL)
let audioAsset = AVURLAsset(url: audioURL)
let videoTracks = try await videoAsset.loadTracks(withMediaType: .video)
let audioTracks = try await audioAsset.loadTracks(withMediaType: .audio)
guard let srcVideo = videoTracks.first,
let srcAudio = audioTracks.first else {
throw DownloadError.muxFailed("Downloaded streams missing expected tracks")
}
let composition = AVMutableComposition()
guard let compVideo = composition.addMutableTrack(
withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid
),
let compAudio = composition.addMutableTrack(
withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid
) else {
throw DownloadError.muxFailed("Could not create composition tracks")
}
let videoDuration = try await videoAsset.load(.duration)
let audioDuration = try await audioAsset.load(.duration)
let duration = CMTimeMinimum(videoDuration, audioDuration)
let range = CMTimeRange(start: .zero, duration: duration)
try compVideo.insertTimeRange(range, of: srcVideo, at: .zero)
try compAudio.insertTimeRange(range, of: srcAudio, at: .zero)
if FileManager.default.fileExists(atPath: outputURL.path) {
try FileManager.default.removeItem(at: outputURL)
}
// Try passthrough (no re-encode) first; fall back to quality export on failure.
let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality]
var lastError: Error?
for preset in presets {
guard let exporter = AVAssetExportSession(asset: composition, presetName: preset) else {
continue
}
do {
try await exporter.export(to: outputURL, as: .mp4)
return
} catch {
lastError = error
// Clean up partial output before retrying with next preset.
try? FileManager.default.removeItem(at: outputURL)
continue
}
}
throw DownloadError.muxFailed(lastError?.localizedDescription ?? "Unknown export failure")
}
}
// MARK: - URLSession progress delegate
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
let onProgress: @Sendable (Double) -> Void
init(onProgress: @escaping @Sendable (Double) -> Void) {
self.onProgress = onProgress
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
guard totalBytesExpectedToWrite > 0 else { return }
onProgress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite))
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
// Not used `URLSession.download(for:delegate:)` returns the temp URL directly.
}
}
@@ -0,0 +1,216 @@
import Foundation
import SharedModels
import SwiftData
/// In-session "learning steps" queue for vocab practice the short-term
/// scheduling layer that sits on top of the cross-session SM-2 schedule.
///
/// A card is requeued a relative number of positions ahead based on the
/// rating, mirroring Anki's learning steps but position-based instead of
/// time-based:
/// Again reappears 58 cards later
/// Hard reappears 710 cards later
/// Good first time: advances to `review`, reappears ~20 cards later
/// already in review: graduates (leaves the session)
/// Easy graduates immediately
///
/// `answer` returns a `ReviewQuality` only when the card graduates that's
/// the single rating fed to the long-term `VerbReviewStore`. Intermediate
/// Again/Hard presses don't touch the cross-session schedule.
struct VocabSessionQueue {
enum CardState: String {
case new // never answered this session
case learning // answered Again/Hard at least once
case review // answered Good once, one confirmation pass to go
}
enum Rating {
case again, hard, good, easy
}
struct Entry: Identifiable {
let id = UUID()
let verb: Verb
var state: CardState
}
private(set) var queue: [Entry]
private(set) var learnedCount: Int = 0
private let originalVerbs: [Verb]
init(verbs: [Verb]) {
originalVerbs = verbs
queue = verbs.map { Entry(verb: $0, state: .new) }
}
/// Resume a persisted group: rebuild the queue from saved (verb, state)
/// pairs in order, restoring the learned count. The exact requeue
/// positions aren't persisted the queue order itself is what's saved.
init(entries: [(verb: Verb, state: CardState)], learnedCount: Int) {
originalVerbs = entries.map(\.verb)
queue = entries.map { Entry(verb: $0.verb, state: $0.state) }
self.learnedCount = learnedCount
}
/// Current queue (un-graduated cards) in order for persistence.
func snapshot() -> [(verbId: Int, state: CardState)] {
queue.map { ($0.verb.id, $0.state) }
}
// MARK: - State
var current: Entry? { queue.first }
var isComplete: Bool { queue.isEmpty }
var remainingCount: Int { queue.count }
/// 01, climbs as cards graduate. Requeuing a card lowers it slightly but
/// it always trends to 1 as the session drains.
var progress: Double {
let total = learnedCount + queue.count
return total == 0 ? 1 : Double(learnedCount) / Double(total)
}
// MARK: - Answering
/// Apply a rating to the current card. Returns the `ReviewQuality` to
/// record in the long-term SRS *iff* the card graduated; nil if it was
/// requeued for more in-session practice.
@discardableResult
mutating func answer(_ rating: Rating) -> ReviewQuality? {
guard !queue.isEmpty else { return nil }
var entry = queue.removeFirst()
switch rating {
// Again/Hard always (re)set the card to `.learning`. A card already in
// `.review` therefore drops back a step an intentional lapse, matching
// Anki: missing a card you'd previously passed sends it back through the
// learning steps rather than letting it graduate.
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
}
}
/// Rebuild the session from the same verb set (re-shuffled) "Study Again".
mutating func restart() {
queue = originalVerbs.shuffled().map { Entry(verb: $0, state: .new) }
learnedCount = 0
}
// MARK: - Private
/// Insert `entry` `offset` positions from the front, i.e. `offset` other
/// cards will be shown before it reappears. Clamps to the queue's end.
private mutating func insert(_ entry: Entry, offset: Int) {
let idx = min(queue.count, offset)
queue.insert(entry, at: idx)
}
}
// MARK: - Session verb pool
/// Builds a vocab-practice session: level-filtered verbs ordered due-first
/// (per the cross-session `VerbReviewCard` schedule) and capped so a single
/// sitting is bounded proper SRS behaviour rather than a 100+ card slog.
enum VocabVerbPool {
/// Maximum verbs in one session, from the "Cards per session" setting
/// (`vocabSessionCardLimit`). Defaults to 20 when unset; 999 means "All".
/// Overdue cards are pulled first, then new (never-reviewed) verbs.
static var sessionCardLimit: Int {
let stored = UserDefaults.standard.integer(forKey: "vocabSessionCardLimit")
return stored == 0 ? 20 : stored
}
static func sessionVerbs(
localContext: ModelContext,
cloudContext: ModelContext
) -> [Verb] {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let levels = Set(progress.selectedVerbLevels.map(\.rawValue))
let store = ReferenceStore(context: localContext)
let pool = levels.isEmpty
? store.fetchVerbs()
: store.fetchVerbs(selectedLevels: levels)
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
let cardByVerbId = Dictionary(
reviewCards.map { ($0.verbId, $0) },
uniquingKeysWith: { existing, _ in existing }
)
let now = Date()
var due: [(verb: Verb, dueDate: Date)] = []
var fresh: [Verb] = []
for verb in pool {
if let card = cardByVerbId[verb.id] {
if card.dueDate <= now {
due.append((verb, card.dueDate))
}
// Not yet due intentionally skipped; that's the SRS schedule.
} else {
fresh.append(verb)
}
}
// Most-overdue first, then new verbs (lower rank = more common first).
due.sort { $0.dueDate < $1.dueDate }
fresh.sort { $0.rank < $1.rank }
let ordered = due.map(\.verb) + fresh
return Array(ordered.prefix(sessionCardLimit))
}
/// Verbs the user has already studied at least once (have a
/// `VerbReviewCard`), most-recently-studied first. Used by the
/// "Review Learned" consolidation pass ignores due dates and the
/// Level filter, and is uncapped: it's a deliberate cram over
/// everything you've learned.
static func reviewLearnedVerbs(
localContext: ModelContext,
cloudContext: ModelContext
) -> [Verb] {
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
let sorted = reviewCards.sorted {
($0.lastReviewDate ?? .distantPast) > ($1.lastReviewDate ?? .distantPast)
}
let allVerbs = ReferenceStore(context: localContext).fetchVerbs()
let byId = Dictionary(allVerbs.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
return sorted.compactMap { byId[$0.verbId] }
}
}
/// Canonical 6-tense set for `VerbExampleGenerator`. Its `@Generable` schema
/// requires exactly 6 examples, so callers must pass 6 distinct tense IDs.
enum VocabExampleTenseIds {
static let canonical: [String] = [
TenseID.ind_presente.rawValue,
TenseID.ind_preterito.rawValue,
TenseID.ind_imperfecto.rawValue,
TenseID.ind_futuro.rawValue,
TenseID.subj_presente.rawValue,
TenseID.imp_afirmativo.rawValue,
]
}
@@ -0,0 +1,61 @@
import Foundation
/// Curated YouTube-video lookup for guide + grammar items (Issue #21).
/// Loads the bundled `youtube_videos.json` at init, serves tense-guide and
/// grammar-note videos by id. The data is static after load; `static let shared`
/// lets services access it without environment injection.
@MainActor
@Observable
final class YouTubeVideoStore {
struct VideoEntry: Codable, Hashable, Sendable, Identifiable {
let videoId: String
let title: String
var id: String { videoId }
}
static let shared = YouTubeVideoStore()
private(set) var tenseVideos: [String: VideoEntry] = [:]
private(set) var grammarVideos: [String: VideoEntry] = [:]
init(bundle: Bundle = .main) {
load(from: bundle)
}
/// Returns the curated video for a tense guide, or nil if unmapped.
func video(forTenseId id: String) -> VideoEntry? {
tenseVideos[id]
}
/// Returns the curated video for a grammar note, or nil if unmapped.
func video(forGrammarNoteId id: String) -> VideoEntry? {
grammarVideos[id]
}
/// All distinct videoIds present in the store. Useful for bulk operations
/// like "download all" or cache cleanup.
var allVideoIds: Set<String> {
Set(tenseVideos.values.map(\.videoId)).union(grammarVideos.values.map(\.videoId))
}
private func load(from bundle: Bundle) {
guard let url = bundle.url(forResource: "youtube_videos", withExtension: "json"),
let data = try? Data(contentsOf: url) else {
print("[YouTubeVideoStore] bundled youtube_videos.json not found")
return
}
struct Root: Decodable {
let tenseGuides: [String: VideoEntry]
let grammarNotes: [String: VideoEntry]
}
do {
let root = try JSONDecoder().decode(Root.self, from: data)
tenseVideos = root.tenseGuides
grammarVideos = root.grammarNotes
print("[YouTubeVideoStore] loaded \(tenseVideos.count) tense + \(grammarVideos.count) grammar entries")
} catch {
print("[YouTubeVideoStore] decode failed: \(error)")
}
}
}
@@ -96,7 +96,11 @@ final class PracticeViewModel {
currentSpans = []
hasCards = true
isLoading = true
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
let service = PracticeSessionService(
localContext: localContext,
cloudContext: cloudContext,
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
)
guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else {
clearCurrentCard()
hasCards = false
@@ -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
}
@@ -585,6 +585,7 @@ struct CourseQuizView: View {
)
cloudModelContext.insert(result)
try? cloudModelContext.save()
ReviewStore.recordActivity(context: cloudModelContext)
}
}
+131 -1
View File
@@ -5,8 +5,18 @@ import SwiftData
struct CourseView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
@Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter]
@AppStorage("selectedCourse") private var selectedCourse: String?
@State private var testResults: [TestResult] = []
@State private var extraStudyCounts: [Int: Int] = [:]
private var textbookCourses: [String] {
Array(Set(textbookChapters.map(\.courseName))).sorted()
}
private var activeCourseIsTextbook: Bool {
textbookCourses.contains(activeCourse)
}
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@@ -47,6 +57,11 @@ struct CourseView: View {
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 {
full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
.replacingOccurrences(of: "LanGo Spanish ", with: "")
@@ -62,6 +77,32 @@ struct CourseView: View {
description: Text("Course data is loading...")
)
} else {
// Textbook entry (shown above course picker when available)
if !textbookCourses.isEmpty {
Section {
ForEach(textbookCourses, id: \.self) { name in
NavigationLink(value: TextbookDestination(courseName: name)) {
HStack(spacing: 12) {
Image(systemName: "book.fill")
.font(.title3)
.foregroundStyle(.indigo)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(name)
.font(.subheadline.weight(.semibold))
Text("Read chapters, do exercises")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
} header: {
Text("Textbook")
}
}
// Course picker
if courseNames.count > 1 {
Section {
@@ -80,6 +121,28 @@ struct CourseView: View {
// Week sections
ForEach(weekGroups, id: \.week) { week, weekDecks in
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
NavigationLink(value: WeekTestDestination(courseName: activeCourse, weekNumber: week)) {
HStack(spacing: 12) {
@@ -138,6 +201,28 @@ struct CourseView: View {
}
}
}
// Extra Study row only when there are marks for this week
if !activeCourseIsTextbook, let markCount = extraStudyCounts[week], markCount > 0 {
NavigationLink(value: ExtraStudyDestination(courseName: activeCourse, weekNumber: week)) {
HStack(spacing: 12) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.yellow)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Extra Study")
.font(.subheadline.weight(.semibold))
Text("\(markCount) marked card\(markCount == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
} header: {
Text("Week \(week)")
}
@@ -145,22 +230,58 @@ struct CourseView: View {
}
}
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
.onAppear(perform: loadTestResults)
.onAppear {
loadTestResults()
loadExtraStudyCounts()
}
.onChange(of: activeCourse) { _, _ in
loadExtraStudyCounts()
}
.navigationDestination(for: CourseDeck.self) { deck in
DeckStudyView(deck: deck)
}
.navigationDestination(for: ExtraStudyDestination.self) { dest in
ExtraStudyView(courseName: dest.courseName, weekNumber: dest.weekNumber)
}
.navigationDestination(for: WeekTestDestination.self) { dest in
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
}
.navigationDestination(for: CheckpointDestination.self) { dest in
CheckpointExamView(courseName: dest.courseName, throughWeek: dest.throughWeek)
}
.navigationDestination(for: TextbookDestination.self) { dest in
TextbookChapterListView(courseName: dest.courseName)
}
.navigationDestination(for: TextbookChapter.self) { chapter in
TextbookChapterView(chapter: chapter)
}
.navigationDestination(for: TextbookExerciseDestination.self) { dest in
textbookExerciseView(for: dest)
}
.navigationDestination(for: CourseMaterialDestination.self) { dest in
CourseMaterialView(weekNumber: dest.weekNumber, courseName: dest.courseName)
}
}
}
@ViewBuilder
private func textbookExerciseView(for dest: TextbookExerciseDestination) -> some View {
if let chapter = textbookChapters.first(where: { $0.id == dest.chapterId }) {
TextbookExerciseView(chapter: chapter, blockIndex: dest.blockIndex)
} else {
ContentUnavailableView("Exercise unavailable", systemImage: "questionmark.circle")
}
}
private func loadTestResults() {
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
}
private func loadExtraStudyCounts() {
guard !activeCourse.isEmpty else { return }
extraStudyCounts = ExtraStudyStore(context: cloudModelContext)
.countsByWeek(courseName: activeCourse)
}
}
// MARK: - Navigation
@@ -175,6 +296,15 @@ struct CheckpointDestination: Hashable {
let throughWeek: Int
}
struct TextbookDestination: Hashable {
let courseName: String
}
struct ExtraStudyDestination: Hashable {
let courseName: String
let weekNumber: Int
}
// MARK: - Deck Row
private struct DeckRowView: View {
@@ -5,9 +5,29 @@ import SwiftData
struct DeckStudyView: View {
let deck: CourseDeck
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query private var textbookChapters: [TextbookChapter]
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var isStudying = false
@State private var speechService = SpeechService()
@State private var deckCards: [VocabCard] = []
@State private var expandedConjugations: Set<String> = []
private var isStemChangingDeck: Bool {
deck.title.localizedCaseInsensitiveContains("stem changing")
}
private var isTextbookDeck: Bool {
textbookChapters.contains { $0.courseName == deck.courseName }
}
private var markContext: ExtraStudyMarkContext? {
guard !isTextbookDeck else { return nil }
return ExtraStudyMarkContext(
courseName: deck.courseName,
weekNumber: deck.weekNumber
)
}
var body: some View {
cardListView
@@ -19,7 +39,12 @@ struct DeckStudyView: View {
VocabFlashcardView(
cards: deckCards.shuffled(),
speechService: speechService,
onDone: { isStudying = false }
onDone: {
ReviewStore.recordActivity(context: cloudModelContext)
isStudying = false
},
deckTitle: deck.title,
markContext: markContext
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -30,6 +55,24 @@ struct DeckStudyView: View {
}
}
/// Reversed stem-change decks have `front` as English, so prefer the
/// Spanish side when the card is stored that way. Strip parenthetical
/// notes and the reflexive `-se` ending for verb-table lookup.
private func inferInfinitive(card: VocabCard) -> String {
let raw: String
if deck.isReversed {
raw = card.back
} else {
raw = card.front
}
var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if let paren = t.firstIndex(of: "(") {
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
}
if t.hasSuffix("se") && t.count > 4 { t = String(t.dropLast(2)) }
return t
}
private func loadCards() {
let deckId = deck.id
let descriptor = FetchDescriptor<VocabCard>(
@@ -107,6 +150,36 @@ struct DeckStudyView: View {
.multilineTextAlignment(.trailing)
}
// Stem-change conjugation toggle
if isStemChangingDeck {
let verb = inferInfinitive(card: card)
let isOpen = expandedConjugations.contains(verb)
Button {
withAnimation(.smooth) {
if isOpen {
expandedConjugations.remove(verb)
} else {
expandedConjugations.insert(verb)
}
}
} label: {
Label(
isOpen ? "Hide conjugation" : "Show conjugation",
systemImage: isOpen ? "chevron.up" : "chevron.down"
)
.font(.caption.weight(.medium))
}
.buttonStyle(.borderless)
.tint(.blue)
.padding(.leading, 42)
if isOpen {
StemChangeConjugationView(infinitive: verb)
.padding(.leading, 42)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// Example sentences
if !card.examplesES.isEmpty {
VStack(alignment: .leading, spacing: 6) {
@@ -0,0 +1,75 @@
import SwiftUI
import SharedModels
import SwiftData
/// Study session containing only cards the user has marked for extra study,
/// scoped to a specific (courseName, weekNumber). Resolves marks by re-hashing
/// each VocabCard via `CourseCardStore.reviewKey` so the matching is robust to
/// duplicate (deckId, front, back) tuples that differ in examples.
struct ExtraStudyView: View {
let courseName: String
let weekNumber: Int
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var cards: [VocabCard] = []
@State private var speechService = SpeechService()
@State private var loaded = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
Group {
if !loaded {
ProgressView()
.controlSize(.large)
} else if cards.isEmpty {
ContentUnavailableView(
"No Marked Cards",
systemImage: "star",
description: Text("Tap the star on a card during study to add it here.")
)
} else {
VocabFlashcardView(
cards: cards.shuffled(),
speechService: speechService,
onDone: {
ReviewStore.recordActivity(context: cloudContext)
dismiss()
},
deckTitle: "Extra Study",
markContext: ExtraStudyMarkContext(
courseName: courseName,
weekNumber: weekNumber
)
)
}
}
.navigationTitle("Extra Study · Week \(weekNumber)")
.navigationBarTitleDisplayMode(.inline)
.task { load() }
}
private func load() {
guard !loaded else { return }
let marks = ExtraStudyStore(context: cloudContext)
.fetch(courseName: courseName, weekNumber: weekNumber)
let markIds = Set(marks.map(\.id))
let deckIds = Set(marks.map(\.deckId))
var collected: [VocabCard] = []
for deckId in deckIds {
let descriptor = FetchDescriptor<VocabCard>(
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
)
let deckCards = (try? localContext.fetch(descriptor)) ?? []
collected.append(contentsOf: deckCards.filter {
markIds.contains(CourseCardStore.reviewKey(for: $0))
})
}
cards = collected
loaded = true
}
}
@@ -0,0 +1,97 @@
import SwiftUI
import SharedModels
import SwiftData
/// Shows the present-tense conjugation of a verb (identified by infinitive),
/// with any irregular/stem-change spans highlighted. Designed to drop into
/// stem-changing verb flashcards so learners can see the conjugation in-place.
struct StemChangeConjugationView: View {
let infinitive: String
@Environment(\.modelContext) private var modelContext
@State private var rows: [ConjugationRow] = []
private static let personLabels = ["yo", "", "él/ella/Ud.", "nosotros", "vosotros", "ellos/ellas/Uds."]
private static let tenseId = "ind_presente"
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Present tense")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
}
if rows.isEmpty {
Text("Conjugation not available")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 4)
} else {
VStack(spacing: 6) {
ForEach(rows) { row in
HStack(alignment: .firstTextBaseline) {
Text(row.person)
.font(.callout)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .leading)
IrregularHighlightText(
form: row.form,
spans: row.spans,
font: .callout.monospaced(),
showLabels: false
)
Spacer()
}
}
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.onAppear(perform: loadForms)
}
private func loadForms() {
// Find the verb by infinitive (lowercase exact match).
let normalized = infinitive.lowercased().trimmingCharacters(in: .whitespaces)
let verbDescriptor = FetchDescriptor<Verb>(
predicate: #Predicate<Verb> { $0.infinitive == normalized }
)
guard let verb = (try? modelContext.fetch(verbDescriptor))?.first else {
rows = []
return
}
let verbId = verb.id
let tenseId = Self.tenseId
let formDescriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { $0.verbId == verbId && $0.tenseId == tenseId },
sortBy: [SortDescriptor(\VerbForm.personIndex)]
)
let forms = (try? modelContext.fetch(formDescriptor)) ?? []
rows = forms.map { f in
ConjugationRow(
id: f.personIndex,
person: Self.personLabels[safe: f.personIndex] ?? "",
form: f.form,
spans: f.spans ?? []
)
}
}
}
private struct ConjugationRow: Identifiable {
let id: Int
let person: String
let form: String
let spans: [IrregularSpan]
}
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -0,0 +1,121 @@
import SwiftUI
import SharedModels
import SwiftData
struct TextbookChapterListView: View {
let courseName: String
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query(sort: \TextbookChapter.number) private var allChapters: [TextbookChapter]
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var attempts: [TextbookExerciseAttempt] = []
private var chapters: [TextbookChapter] {
allChapters.filter { $0.courseName == courseName }
}
private var byPart: [(part: Int, chapters: [TextbookChapter])] {
let grouped = Dictionary(grouping: chapters, by: \.part)
return grouped.keys.sorted().map { p in
(p, grouped[p]!.sorted { $0.number < $1.number })
}
}
private func progressFor(_ chapter: TextbookChapter) -> (correct: Int, total: Int) {
let chNum = chapter.number
let chAttempts = attempts.filter {
$0.courseName == courseName && $0.chapterNumber == chNum
}
let total = chAttempts.reduce(0) { $0 + $1.totalCount }
let correct = chAttempts.reduce(0) { $0 + $1.correctCount + $1.closeCount }
return (correct, total)
}
var body: some View {
List {
if chapters.isEmpty {
ContentUnavailableView(
"Textbook loading",
systemImage: "book.closed",
description: Text("Textbook content is being prepared…")
)
} else {
ForEach(byPart, id: \.part) { part, partChapters in
Section {
ForEach(partChapters, id: \.id) { chapter in
NavigationLink(value: chapter) {
chapterRow(chapter)
}
.accessibilityIdentifier("textbook-chapter-row-\(chapter.number)")
}
} header: {
if part > 0 {
Text("Part \(part)")
} else {
Text("Chapters")
}
}
}
}
}
.navigationTitle("Textbook")
.onAppear(perform: loadAttempts)
}
@ViewBuilder
private func chapterRow(_ chapter: TextbookChapter) -> some View {
let p = progressFor(chapter)
HStack(alignment: .center, spacing: 12) {
ZStack {
Circle()
.stroke(Color.secondary.opacity(0.2), lineWidth: 3)
.frame(width: 36, height: 36)
if p.total > 0 {
Circle()
.trim(from: 0, to: CGFloat(p.correct) / CGFloat(p.total))
.stroke(.orange, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 36, height: 36)
.rotationEffect(.degrees(-90))
}
Text("\(chapter.number)")
.font(.footnote.weight(.bold))
}
VStack(alignment: .leading, spacing: 2) {
Text(chapter.title)
.font(.headline)
HStack(spacing: 10) {
if chapter.exerciseCount > 0 {
Label("\(chapter.exerciseCount)", systemImage: "pencil.and.list.clipboard")
.font(.caption)
.foregroundStyle(.secondary)
}
if chapter.vocabTableCount > 0 {
Label("\(chapter.vocabTableCount)", systemImage: "list.bullet.rectangle")
.font(.caption)
.foregroundStyle(.secondary)
}
if p.total > 0 {
Text("\(p.correct)/\(p.total)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
Spacer()
}
.padding(.vertical, 4)
}
private func loadAttempts() {
attempts = (try? cloudModelContext.fetch(FetchDescriptor<TextbookExerciseAttempt>())) ?? []
}
}
#Preview {
NavigationStack {
TextbookChapterListView(courseName: "Complete Spanish Step-by-Step")
}
.modelContainer(for: [TextbookChapter.self], inMemory: true)
}
@@ -0,0 +1,209 @@
import SwiftUI
import SharedModels
import SwiftData
struct TextbookChapterView: View {
let chapter: TextbookChapter
@State private var expandedVocab: Set<Int> = []
private var blocks: [TextbookBlock] { chapter.blocks() }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
headerView
Divider()
ForEach(blocks) { block in
blockView(block)
}
}
.padding(.horizontal)
.padding(.vertical, 12)
}
.navigationTitle(chapter.title)
.navigationBarTitleDisplayMode(.inline)
}
private var headerView: some View {
VStack(alignment: .leading, spacing: 4) {
if chapter.part > 0 {
Text("Part \(chapter.part)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text("Chapter \(chapter.number)")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(chapter.title)
.font(.largeTitle.bold())
}
}
@ViewBuilder
private func blockView(_ block: TextbookBlock) -> some View {
switch block.kind {
case .heading:
headingView(block)
case .paragraph:
paragraphView(block)
case .keyVocabHeader:
HStack(spacing: 6) {
Image(systemName: "star.fill").foregroundStyle(.orange)
Text("Key Vocabulary")
.font(.headline)
.foregroundStyle(.orange)
}
.padding(.top, 8)
case .vocabTable:
vocabTableView(block)
case .exercise:
exerciseLinkView(block)
}
}
private func headingView(_ block: TextbookBlock) -> some View {
let level = block.level ?? 3
let font: Font
switch level {
case 2: font = .title.bold()
case 3: font = .title2.bold()
case 4: font = .title3.weight(.semibold)
default: font = .headline
}
return Text(stripInlineEmphasis(block.text ?? ""))
.font(font)
.padding(.top, 10)
}
private func paragraphView(_ block: TextbookBlock) -> some View {
Text(attributedFromMarkdownish(block.text ?? ""))
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
private func vocabTableView(_ block: TextbookBlock) -> some View {
let expanded = expandedVocab.contains(block.index)
let cards = block.cards ?? []
let lines = block.ocrLines ?? []
let itemCount = cards.isEmpty ? lines.count : cards.count
return VStack(alignment: .leading, spacing: 4) {
Button {
if expanded { expandedVocab.remove(block.index) } else { expandedVocab.insert(block.index) }
} label: {
HStack {
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.caption)
Text("Vocabulary (\(itemCount) items)")
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if expanded {
if cards.isEmpty {
// Fallback: no paired cards available show raw OCR lines.
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
}
}
.padding(.leading, 14)
} else {
vocabGrid(cards: cards)
.padding(.leading, 14)
}
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private func vocabGrid(cards: [TextbookVocabPair]) -> some View {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
ForEach(Array(cards.enumerated()), id: \.offset) { _, card in
GridRow {
Text(card.front)
.font(.callout)
.foregroundStyle(.primary)
Text(card.back)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
}
private func exerciseLinkView(_ block: TextbookBlock) -> some View {
NavigationLink(value: TextbookExerciseDestination(
chapterId: chapter.id,
chapterNumber: chapter.number,
blockIndex: block.index
)) {
HStack(spacing: 10) {
Image(systemName: "pencil.and.list.clipboard")
.foregroundStyle(.orange)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Exercise \(block.exerciseId ?? "")")
.font(.headline)
if let inst = block.instruction, !inst.isEmpty {
Text(stripInlineEmphasis(inst))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
.font(.caption)
}
.padding(12)
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
// Strip our ad-hoc ** / * markers from parsed text
private func stripInlineEmphasis(_ s: String) -> String {
s.replacingOccurrences(of: "**", with: "")
.replacingOccurrences(of: "*", with: "")
}
private func attributedFromMarkdownish(_ s: String) -> AttributedString {
// Parser emits `**bold**` and `*italic*`. Try to render via AttributedString markdown.
if let parsed = try? AttributedString(markdown: s, options: .init(allowsExtendedAttributes: true)) {
return parsed
}
return AttributedString(stripInlineEmphasis(s))
}
}
struct TextbookExerciseDestination: Hashable {
let chapterId: String
let chapterNumber: Int
let blockIndex: Int
}
#Preview {
NavigationStack {
TextbookChapterView(chapter: TextbookChapter(
id: "ch1",
number: 1,
title: "Sample",
part: 1,
courseName: "Preview",
bodyJSON: Data(),
exerciseCount: 0,
vocabTableCount: 0
))
}
}
@@ -0,0 +1,361 @@
import SwiftUI
import SharedModels
import SwiftData
import PencilKit
/// Interactive fill-in-the-blank view for one textbook exercise.
/// Supports keyboard typing OR Apple Pencil handwriting input per prompt.
struct TextbookExerciseView: View {
let chapter: TextbookChapter
let blockIndex: Int
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var answers: [Int: String] = [:]
@State private var drawings: [Int: PKDrawing] = [:]
@State private var grades: [Int: TextbookGrade] = [:]
@State private var inputMode: InputMode = .keyboard
@State private var activePencilPromptNumber: Int?
@State private var isRecognizing = false
@State private var isChecked = false
@State private var recognizedTextForActive: String = ""
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
enum InputMode: String {
case keyboard
case pencil
}
private var block: TextbookBlock? {
chapter.blocks().first { $0.index == blockIndex }
}
private var answerByNumber: [Int: TextbookAnswerItem] {
guard let items = block?.answerItems else { return [:] }
var out: [Int: TextbookAnswerItem] = [:]
for it in items {
out[it.number] = it
}
return out
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let b = block {
headerView(b)
inputModePicker
exerciseBody(b)
checkButton(b)
} else {
ContentUnavailableView(
"Exercise not found",
systemImage: "questionmark.circle"
)
}
}
.padding()
}
.navigationTitle("Exercise \(block?.exerciseId ?? "")")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadPreviousAttempt)
}
private func headerView(_ b: TextbookBlock) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Chapter \(chapter.number): \(chapter.title)")
.font(.caption)
.foregroundStyle(.secondary)
Text("Exercise \(b.exerciseId ?? "")")
.font(.title2.bold())
if let inst = b.instruction, !inst.isEmpty {
Text(stripInlineEmphasis(inst))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let extra = b.extra, !extra.isEmpty {
ForEach(Array(extra.enumerated()), id: \.offset) { _, e in
Text(stripInlineEmphasis(e))
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
}
}
}
}
private var inputModePicker: some View {
Picker("Input mode", selection: $inputMode) {
Label("Keyboard", systemImage: "keyboard").tag(InputMode.keyboard)
Label("Pencil", systemImage: "pencil.tip").tag(InputMode.pencil)
}
.pickerStyle(.segmented)
}
private func exerciseBody(_ b: TextbookBlock) -> some View {
VStack(alignment: .leading, spacing: 14) {
if b.freeform == true {
VStack(alignment: .leading, spacing: 6) {
Label("Freeform exercise", systemImage: "text.bubble")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
Text("Answers will vary. Use this space to write your own responses; they won't be auto-checked.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
}
let rawPrompts = b.prompts ?? []
let prompts = rawPrompts.isEmpty ? synthesizedPrompts(b) : rawPrompts
if prompts.isEmpty && b.extra?.isEmpty == false {
Text("Fill in the blanks above; answers will be graded when you tap Check.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(Array(prompts.enumerated()), id: \.offset) { i, prompt in
promptRow(index: i, prompt: prompt, expected: answerByNumber[i + 1])
}
}
}
}
/// When the source exercise prompts were embedded in a bitmap (common in
/// this textbook), we have no text for each question only the answer
/// key. Synthesize numbered placeholders so the user still gets one input
/// field per answer.
private func synthesizedPrompts(_ b: TextbookBlock) -> [String] {
guard let items = b.answerItems, !items.isEmpty else { return [] }
return items.map { "\($0.number)." }
}
private func promptRow(index: Int, prompt: String, expected: TextbookAnswerItem?) -> some View {
let number = index + 1
let grade = grades[number]
return VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 8) {
if let grade {
Image(systemName: iconFor(grade))
.foregroundStyle(colorFor(grade))
.font(.title3)
.padding(.top, 2)
}
Text(stripInlineEmphasis(prompt))
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
switch inputMode {
case .keyboard:
TextField("Your answer", text: binding(for: number))
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.font(.body)
.disabled(isChecked)
case .pencil:
pencilRow(number: number)
}
if isChecked, let grade, grade != .correct, let expected {
HStack(spacing: 6) {
Text("Answer:")
.font(.caption.weight(.semibold))
Text(expected.answer)
.font(.caption)
if !expected.alternates.isEmpty {
Text("(also: \(expected.alternates.joined(separator: ", ")))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.foregroundStyle(colorFor(grade))
}
}
.padding(10)
.background(backgroundFor(grade), in: RoundedRectangle(cornerRadius: 8))
}
private func pencilRow(number: Int) -> some View {
VStack(alignment: .leading, spacing: 6) {
HandwritingCanvas(
drawing: bindingDrawing(for: number),
onDrawingChanged: { recognizePencil(for: number) }
)
.frame(height: 100)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.separator, lineWidth: 1))
HStack {
if let typed = answers[number], !typed.isEmpty {
Text("Recognized: \(typed)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Clear") {
drawings[number] = PKDrawing()
answers[number] = ""
}
.font(.caption)
.tint(.secondary)
}
}
}
private func checkButton(_ b: TextbookBlock) -> some View {
let hasAnyAnswer = answers.values.contains { !$0.isEmpty }
let disabled = b.freeform == true || (!isChecked && !hasAnyAnswer)
return Button {
if isChecked {
resetExercise()
} else {
checkAnswers(b)
}
} label: {
Text(isChecked ? "Try again" : "Check answers")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.disabled(disabled)
}
// MARK: - Actions
private func checkAnswers(_ b: TextbookBlock) {
guard let prompts = b.prompts else { return }
var newGrades: [Int: TextbookGrade] = [:]
var states: [TextbookPromptState] = []
for (i, _) in prompts.enumerated() {
let number = i + 1
let user = answers[number] ?? ""
let expected = answerByNumber[number]
let canonical = expected?.answer ?? ""
let alts = expected?.alternates ?? []
let grade: TextbookGrade
if canonical.isEmpty {
grade = .wrong
} else {
grade = AnswerChecker.grade(userText: user, canonical: canonical, alternates: alts)
}
newGrades[number] = grade
states.append(TextbookPromptState(number: number, userText: user, grade: grade))
}
grades = newGrades
isChecked = true
ReviewStore.recordActivity(context: cloudModelContext)
saveAttempt(states: states, exerciseId: b.exerciseId ?? "")
}
private func resetExercise() {
answers.removeAll()
drawings.removeAll()
grades.removeAll()
isChecked = false
}
private func recognizePencil(for number: Int) {
guard let drawing = drawings[number], !drawing.strokes.isEmpty else { return }
isRecognizing = true
Task {
let result = await HandwritingRecognizer.recognize(drawing: drawing)
await MainActor.run {
answers[number] = result.text
isRecognizing = false
}
}
}
private func saveAttempt(states: [TextbookPromptState], exerciseId: String) {
let attemptId = TextbookExerciseAttempt.attemptId(
courseName: chapter.courseName,
exerciseId: exerciseId
)
let descriptor = FetchDescriptor<TextbookExerciseAttempt>(
predicate: #Predicate<TextbookExerciseAttempt> { $0.id == attemptId }
)
let context = cloudModelContext
let existing = (try? context.fetch(descriptor))?.first
let attempt = existing ?? TextbookExerciseAttempt(
id: attemptId,
courseName: chapter.courseName,
chapterNumber: chapter.number,
exerciseId: exerciseId
)
if existing == nil { context.insert(attempt) }
attempt.lastAttemptAt = Date()
attempt.setPromptStates(states)
try? context.save()
}
private func loadPreviousAttempt() {
guard let b = block else { return }
let attemptId = TextbookExerciseAttempt.attemptId(
courseName: chapter.courseName,
exerciseId: b.exerciseId ?? ""
)
let descriptor = FetchDescriptor<TextbookExerciseAttempt>(
predicate: #Predicate<TextbookExerciseAttempt> { $0.id == attemptId }
)
guard let attempt = (try? cloudModelContext.fetch(descriptor))?.first else { return }
for s in attempt.promptStates() {
answers[s.number] = s.userText
grades[s.number] = s.grade
}
isChecked = !grades.isEmpty
}
// MARK: - Bindings
private func binding(for number: Int) -> Binding<String> {
Binding(
get: { answers[number] ?? "" },
set: { answers[number] = $0 }
)
}
private func bindingDrawing(for number: Int) -> Binding<PKDrawing> {
Binding(
get: { drawings[number] ?? PKDrawing() },
set: { drawings[number] = $0 }
)
}
// MARK: - UI helpers
private func iconFor(_ grade: TextbookGrade) -> String {
switch grade {
case .correct: return "checkmark.circle.fill"
case .close: return "circle.lefthalf.filled"
case .wrong: return "xmark.circle.fill"
}
}
private func colorFor(_ grade: TextbookGrade) -> Color {
switch grade {
case .correct: return .green
case .close: return .orange
case .wrong: return .red
}
}
private func backgroundFor(_ grade: TextbookGrade?) -> Color {
guard let grade else { return Color.secondary.opacity(0.05) }
switch grade {
case .correct: return .green.opacity(0.12)
case .close: return .orange.opacity(0.12)
case .wrong: return .red.opacity(0.12)
}
}
private func stripInlineEmphasis(_ s: String) -> String {
s.replacingOccurrences(of: "**", with: "")
.replacingOccurrences(of: "*", with: "")
}
}
@@ -6,11 +6,23 @@ struct VocabFlashcardView: View {
let cards: [VocabCard]
let speechService: SpeechService
let onDone: () -> Void
/// Optional deck context when present and the title indicates a stem-
/// changing deck, each card gets an inline conjugation toggle.
var deckTitle: String? = nil
/// When set, a star button appears next to the speaker on reveal so the
/// user can mark the card for extra study.
var markContext: ExtraStudyMarkContext? = nil
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var currentIndex = 0
@State private var isRevealed = false
@State private var sessionCorrect = 0
@State private var showConjugation = false
@State private var markedIds: Set<String> = []
private var isStemChangingDeck: Bool {
(deckTitle ?? "").localizedCaseInsensitiveContains("stem changing")
}
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@@ -53,14 +65,48 @@ struct VocabFlashcardView: View {
.font(.title.weight(.medium))
.multilineTextAlignment(.center)
Button {
speechService.speak(card.front)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title3)
.padding(12)
HStack(spacing: 12) {
Button {
speechService.speak(card.front)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title3)
.padding(12)
}
.glassEffect(in: .circle)
if markContext != nil {
Button {
toggleMark()
} label: {
Image(systemName: currentIsMarked ? "star.fill" : "star")
.font(.title3)
.padding(12)
.foregroundStyle(currentIsMarked ? .yellow : .secondary)
}
.glassEffect(in: .circle)
.accessibilityLabel(currentIsMarked ? "Unmark for extra study" : "Mark for extra study")
}
}
if isStemChangingDeck {
Button {
withAnimation(.smooth) { showConjugation.toggle() }
} label: {
Label(
showConjugation ? "Hide conjugation" : "Show conjugation",
systemImage: showConjugation ? "chevron.up" : "chevron.down"
)
.font(.subheadline.weight(.medium))
}
.buttonStyle(.bordered)
.tint(.blue)
if showConjugation {
StemChangeConjugationView(infinitive: stripToInfinitive(card.front))
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.glassEffect(in: .circle)
}
.transition(.blurReplace)
} else {
@@ -111,6 +157,7 @@ struct VocabFlashcardView: View {
guard currentIndex > 0 else { return }
withAnimation(.smooth) {
isRevealed = false
showConjugation = false
currentIndex -= 1
}
} label: {
@@ -125,6 +172,7 @@ struct VocabFlashcardView: View {
Button {
withAnimation(.smooth) {
isRevealed = false
showConjugation = false
currentIndex += 1
}
} label: {
@@ -165,6 +213,34 @@ struct VocabFlashcardView: View {
}
.animation(.smooth, value: isRevealed)
.animation(.smooth, value: currentIndex)
.task { loadMarks() }
}
private var currentIsMarked: Bool {
guard let card = currentCard else { return false }
return markedIds.contains(CourseCardStore.reviewKey(for: card))
}
private func loadMarks() {
guard let ctx = markContext else { return }
markedIds = ExtraStudyStore(context: cloudModelContext)
.fetchIds(courseName: ctx.courseName, weekNumber: ctx.weekNumber)
}
private func toggleMark() {
guard let card = currentCard, let ctx = markContext else { return }
let store = ExtraStudyStore(context: cloudModelContext)
let isNowMarked = store.toggle(
card: card,
courseName: ctx.courseName,
weekNumber: ctx.weekNumber
)
let key = CourseCardStore.reviewKey(for: card)
if isNowMarked {
markedIds.insert(key)
} else {
markedIds.remove(key)
}
}
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
@@ -189,9 +265,25 @@ struct VocabFlashcardView: View {
// Next card
withAnimation(.smooth) {
isRevealed = false
showConjugation = false
currentIndex += 1
}
}
/// Card fronts may be plain infinitives ("cerrar") or, in reversed decks,
/// stored as English. Strip any reflexive-se suffix or parenthetical notes
/// to improve the verb lookup hit rate.
private func stripToInfinitive(_ s: String) -> String {
var t = s.trimmingCharacters(in: .whitespacesAndNewlines)
if let paren = t.firstIndex(of: "(") {
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
}
if t.hasSuffix("se") && t.count > 4 {
// "acostarse" "acostar" for verb lookup
t = String(t.dropLast(2))
}
return t
}
}
#Preview {
@@ -288,7 +288,10 @@ struct DashboardView: View {
}
private func loadData() {
userProgress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
// Reset a stale streak before rendering so the dashboard never lies.
progress.validateStreakIfStale(context: cloudModelContext)
userProgress = progress
let dailyDescriptor = FetchDescriptor<DailyLog>(
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
)
@@ -1,9 +1,12 @@
import SwiftUI
import SwiftData
struct GrammarExerciseView: View {
let noteId: String
let noteTitle: String
@Environment(\.dismiss) private var dismiss
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var exercises: [GrammarExercise] = []
@State private var currentIndex = 0
@@ -96,6 +99,7 @@ struct GrammarExerciseView: View {
currentIndex += 1
selectedOption = nil
} else {
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isFinished = true }
}
} label: {
@@ -1,4 +1,6 @@
import SwiftUI
import SwiftData
import SharedModels
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
struct GrammarNotesView: View {
@@ -19,9 +21,9 @@ struct GrammarNotesListView: View {
@Binding var selectedNote: GrammarNote?
private var groupedNotes: [(String, [GrammarNote])] {
let grouped = Dictionary(grouping: GrammarNote.allNotes, by: \.category)
let grouped = Dictionary(grouping: GrammarNote.allNotesIncludingGenerated, by: \.category)
var seen: [String] = []
for note in GrammarNote.allNotes {
for note in GrammarNote.allNotesIncludingGenerated {
if !seen.contains(note.category) {
seen.append(note.category)
}
@@ -67,6 +69,24 @@ private struct GrammarNoteRow: View {
struct GrammarNoteDetailView: View {
let note: GrammarNote
var onJumpToTense: ((TenseGuide) -> Void)? = nil
@Environment(YouTubeVideoStore.self) private var videoStore
@State private var relatedTenses: [TenseGuide] = []
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
videoStore.video(forGrammarNoteId: note.id)
}
private func loadRelatedTenses() {
guard let container = SharedStore.localContainer else {
relatedTenses = []
return
}
let context = ModelContext(container)
let guides = ReferenceStore(context: context).fetchGuides()
let byId = Dictionary(uniqueKeysWithValues: guides.map { ($0.tenseId, $0) })
relatedTenses = GuideCrossLinks.tenseIds(forNote: note.id).compactMap { byId[$0] }
}
var body: some View {
ScrollView {
@@ -83,6 +103,12 @@ struct GrammarNoteDetailView: View {
.background(.fill.tertiary, in: Capsule())
}
if !relatedTenses.isEmpty {
relatedTensesSection
}
videoSection
Divider()
// Parsed body
@@ -107,6 +133,49 @@ struct GrammarNoteDetailView: View {
}
.navigationTitle(note.title)
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadRelatedTenses)
.onChange(of: note.id) { _, _ in loadRelatedTenses() }
}
private var relatedTensesSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Used in tenses", systemImage: "clock.arrow.circlepath")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(relatedTenses, id: \.tenseId) { guide in
Button {
onJumpToTense?(guide)
} label: {
Text(TenseInfo.find(guide.tenseId)?.english ?? guide.title)
.font(.caption.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(.orange.opacity(0.12), in: Capsule())
.foregroundStyle(.orange)
}
.buttonStyle(.plain)
}
}
}
}
}
@ViewBuilder
private var videoSection: some View {
if let video = curatedVideo {
VideoActionsButtonRow(video: video)
} else {
Label("No video yet", systemImage: "play.slash")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(.fill.quinary, in: Capsule())
}
}
}
+96 -7
View File
@@ -41,15 +41,20 @@ struct GuideView: View {
.navigationTitle("Guide")
.task { loadGuides() }
.onAppear(perform: loadGuides)
.onChange(of: selectedTab) { _, _ in
selectedGuide = nil
selectedNote = nil
.onChange(of: selectedTab) { _, newTab in
// Only clear the *other* tab's selection so programmatic
// cross-link jumps (chip taps in the detail pane) can keep
// their newly-set selection on the destination tab.
switch newTab {
case .tenses: selectedNote = nil
case .grammar: selectedGuide = nil
}
}
} detail: {
if let guide = selectedGuide {
GuideDetailView(guide: guide)
GuideDetailView(guide: guide, onJumpToNote: jumpToNote)
} else if let note = selectedNote {
GrammarNoteDetailView(note: note)
GrammarNoteDetailView(note: note, onJumpToTense: jumpToTense)
} else {
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more."))
}
@@ -79,6 +84,16 @@ struct GuideView: View {
GrammarNotesListView(selectedNote: $selectedNote)
}
private func jumpToNote(_ note: GrammarNote) {
selectedTab = .grammar
selectedNote = note
}
private func jumpToTense(_ guide: TenseGuide) {
selectedTab = .tenses
selectedGuide = guide
}
private func loadGuides() {
// Hit the shared local container directly, bypassing @Environment.
guard let container = SharedStore.localContainer else {
@@ -127,11 +142,23 @@ private struct TenseRowView: View {
struct GuideDetailView: View {
let guide: TenseGuide
var onJumpToNote: ((GrammarNote) -> Void)? = nil
@Environment(YouTubeVideoStore.self) private var videoStore
private var relatedNotes: [GrammarNote] {
GuideCrossLinks.noteIds(forTense: guide.tenseId).compactMap { id in
GrammarNote.allNotesIncludingGenerated.first { $0.id == id }
}
}
private var tenseInfo: TenseInfo? {
TenseInfo.find(guide.tenseId)
}
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
videoStore.video(forTenseId: guide.tenseId)
}
private var endingTable: TenseEndingTable? {
TenseEndingTable.find(guide.tenseId)
}
@@ -146,6 +173,14 @@ struct GuideDetailView: View {
// Header
headerSection
// Related grammar notes cross-links into the Grammar tab
if !relatedNotes.isEmpty {
relatedNotesSection
}
// Video section (Issue #21)
videoSection
// Conjugation ending table
if let table = endingTable {
conjugationTableSection(table)
@@ -180,6 +215,51 @@ struct GuideDetailView: View {
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Related grammar notes
private var relatedNotesSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Related grammar", systemImage: "book")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(relatedNotes, id: \.id) { note in
Button {
onJumpToNote?(note)
} label: {
Text(note.title)
.font(.caption.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(.indigo.opacity(0.12), in: Capsule())
.foregroundStyle(.indigo)
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Video (Issue #21)
@ViewBuilder
private var videoSection: some View {
if let video = curatedVideo {
VideoActionsButtonRow(video: video)
} else {
Label("No video yet", systemImage: "play.slash")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(.fill.quinary, in: Capsule())
}
}
// MARK: - Header
private var headerSection: some View {
@@ -450,14 +530,17 @@ struct GuideContent {
var spanishLine: String?
func flushUsage() {
if currentUsageNumber > 0 {
// Only emit a usage if it has at least one example. This suppresses
// the implicit "Usage 1" seeded when we enter an unnumbered
// *Usages* block but the body actually has numbered headers below.
if currentUsageNumber > 0 && !currentExamples.isEmpty {
usages.append(GuideUsage(
number: currentUsageNumber,
title: currentUsageTitle,
examples: currentExamples
))
currentExamples = []
}
currentExamples = []
}
for line in lines {
@@ -491,6 +574,11 @@ struct GuideContent {
let title = String(match.1).replacingOccurrences(of: "*", with: "")
if title.lowercased().contains("usage") {
inUsages = true
// Seed an implicit Usage 1 so content that follows without a
// numbered "*1 Title*" header still gets captured. Any numbered
// header below will replace this via flushUsage().
currentUsageNumber = 1
currentUsageTitle = "Usage"
continue
}
}
@@ -563,4 +651,5 @@ struct GuideExample: Identifiable {
#Preview {
GuideView()
.modelContainer(for: TenseGuide.self, inMemory: true)
.environment(YouTubeVideoStore())
}
@@ -0,0 +1,245 @@
import SwiftUI
import SwiftData
import AVKit
import SharedModels
/// Three-button row for a curated YouTube video (Issue #21):
/// - **Stream** opens in the YouTube app (falls back to Safari).
/// - **Download** pulls the MP4 via YouTubeKit, shows progress, then enables Play.
/// - **Play** enabled only when the video exists on disk; plays via AVPlayer.
///
/// Used by both `GuideDetailView` and `GrammarNoteDetailView` to keep the
/// video affordances consistent.
struct VideoActionsButtonRow: View {
let video: YouTubeVideoStore.VideoEntry
@Environment(\.openURL) private var openURL
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var downloadService = VideoDownloadService.shared
@State private var isDownloaded: Bool
@State private var playerVideoId: String?
@State private var downloadError: String?
@State private var confirmDelete: Bool = false
init(video: YouTubeVideoStore.VideoEntry) {
self.video = video
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
}
private var activeStatus: VideoDownloadService.DownloadStatus? {
downloadService.activeDownloads[video.videoId]
}
private var isDownloading: Bool {
activeStatus != nil
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(video.title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 10) {
streamButton
downloadButton
playButton
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
.fullScreenCover(item: Binding(
get: { playerVideoId.map { LocalVideoID(videoId: $0) } },
set: { playerVideoId = $0?.videoId }
)) { id in
LocalVideoPlayerSheet(videoId: id.videoId, title: video.title)
}
.alert("Download failed", isPresented: .init(
get: { downloadError != nil },
set: { if !$0 { downloadError = nil } }
)) {
Button("OK") { downloadError = nil }
} message: {
Text(downloadError ?? "")
}
.confirmationDialog(
"Delete this downloaded video?",
isPresented: $confirmDelete,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
downloadService.delete(videoId: video.videoId, modelContext: modelContext)
isDownloaded = false
}
Button("Cancel", role: .cancel) {}
} message: {
Text("You can re-download it at any time.")
}
.onAppear {
// Refresh on appear in case the user deleted the file via Settings.
isDownloaded = VideoDownloadService.isDownloaded(videoId: video.videoId)
}
}
// MARK: - Buttons
private var streamButton: some View {
Button {
if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") {
ReviewStore.recordActivity(context: cloudModelContext)
openURL(url)
}
} label: {
Label("Stream", systemImage: "play.rectangle.fill")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.large)
}
@ViewBuilder
private var downloadButton: some View {
// Single slot whose role flips through the download lifecycle:
// Download progress/label (disabled) Delete.
if let status = activeStatus {
Button {} label: {
VStack(spacing: 4) {
if let progress = status.progress {
ProgressView(value: progress).frame(width: 56)
} else {
ProgressView().controlSize(.small)
}
Text(status.label)
.font(.caption2.monospacedDigit().weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.blue)
.controlSize(.large)
.disabled(true)
} else if isDownloaded {
Button(role: .destructive) {
confirmDelete = true
} label: {
Label("Delete", systemImage: "trash")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.red)
.controlSize(.large)
} else {
Button {
Task { await startDownload() }
} label: {
Label("Download", systemImage: "arrow.down.to.line")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.blue)
.controlSize(.large)
}
}
private var playButton: some View {
Button {
ReviewStore.recordActivity(context: cloudModelContext)
playerVideoId = video.videoId
} label: {
Label("Play", systemImage: "play.fill")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.green)
.controlSize(.large)
.disabled(!isDownloaded)
}
// MARK: - Actions
private func startDownload() async {
do {
try await downloadService.download(
videoId: video.videoId,
title: video.title,
into: modelContext
)
isDownloaded = true
ReviewStore.recordActivity(context: cloudModelContext)
} catch {
downloadError = error.localizedDescription
}
}
}
// MARK: - Helper identifiable wrapper so .sheet(item:) can use a plain String
private struct LocalVideoID: Identifiable {
let videoId: String
var id: String { videoId }
}
// Stacks the icon above the title so three equal-width buttons fit an iPhone
// row without wrapping mid-word.
private struct VideoActionLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(spacing: 4) {
configuration.icon
.imageScale(.large)
configuration.title
.font(.caption2.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
}
// MARK: - Local playback sheet
struct LocalVideoPlayerSheet: View {
let videoId: String
let title: String
@Environment(\.dismiss) private var dismiss
@State private var player: AVPlayer
init(videoId: String, title: String) {
self.videoId = videoId
self.title = title
self._player = State(initialValue: AVPlayer(url: VideoDownloadService.fileURL(for: videoId)))
}
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
VideoPlayer(player: player)
.ignoresSafeArea()
.onAppear { player.play() }
.onDisappear { player.pause() }
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
}
@@ -128,7 +128,7 @@ struct OnboardingView: View {
private func completeOnboarding() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress.selectedVerbLevel = selectedLevel
progress.selectedVerbLevels = [selectedLevel]
if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
}
@@ -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
}
}
@@ -0,0 +1,43 @@
import SwiftUI
import SharedModels
import SwiftData
struct BookChapterListView: View {
let book: Book
@Query private var allChapters: [BookChapter]
init(book: Book) {
self.book = book
let slug = book.slug
_allChapters = Query(
filter: #Predicate<BookChapter> { $0.bookSlug == slug },
sort: \BookChapter.number
)
}
var body: some View {
List {
ForEach(allChapters) { chapter in
NavigationLink(value: chapter) {
HStack(spacing: 12) {
Text("\(chapter.number)")
.font(.subheadline.weight(.bold).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 32, alignment: .trailing)
VStack(alignment: .leading, spacing: 2) {
Text(chapter.title)
.font(.subheadline.weight(.medium))
Text("\(chapter.paragraphCount) paragraph\(chapter.paragraphCount == 1 ? "" : "s")")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
}
}
.navigationTitle(book.title.prefix(while: { $0 != ":" }).description)
.navigationBarTitleDisplayMode(.inline)
}
}
@@ -0,0 +1,105 @@
import SwiftUI
import SharedModels
import SwiftData
/// Route value for pushing the books library. Lets `PracticeView` use a
/// value-based link so the entire books navigation chain is consistent
/// mixing a view-based push with value-based pushes deeper in the same
/// NavigationStack made pushed screens pop back immediately.
enum BooksRoute: Hashable {
case library
}
struct BookLibraryView: View {
@Query(sort: \Book.title) private var books: [Book]
var body: some View {
Group {
if books.isEmpty {
ContentUnavailableView(
"No Books",
systemImage: "books.vertical",
description: Text("Books bundled with the app will appear here.")
)
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(books) { book in
NavigationLink(value: book) {
BookCard(book: book)
}
.tint(.primary)
}
}
.padding()
}
}
}
.navigationTitle("Books")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct BookCard: View {
let book: Book
private var accentColor: Color {
Color(hex: book.accentColorHex) ?? .indigo
}
private var shortTitle: String {
// Trim "Volume X" subtitle if present most book titles are way too long.
if let colon = book.title.firstIndex(of: ":") {
return String(book.title[..<colon])
}
return book.title
}
var body: some View {
HStack(spacing: 14) {
RoundedRectangle(cornerRadius: 6)
.fill(accentColor.gradient)
.frame(width: 48, height: 64)
.overlay {
Image(systemName: "book.closed.fill")
.font(.title3)
.foregroundStyle(.white.opacity(0.9))
}
VStack(alignment: .leading, spacing: 4) {
Text(shortTitle)
.font(.subheadline.weight(.semibold))
.multilineTextAlignment(.leading)
if !book.author.isEmpty {
Text(book.author)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("\(book.chapterCount) chapter\(book.chapterCount == 1 ? "" : "s")")
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
}
private extension Color {
init?(hex: String) {
var s = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if s.hasPrefix("#") { s.removeFirst() }
guard s.count == 6, let v = UInt32(s, radix: 16) else { return nil }
let r = Double((v >> 16) & 0xFF) / 255.0
let g = Double((v >> 8) & 0xFF) / 255.0
let b = Double(v & 0xFF) / 255.0
self = Color(red: r, green: g, blue: b)
}
}
@@ -0,0 +1,434 @@
import SwiftUI
import SharedModels
import SwiftData
import FoundationModels
struct BookReaderView: View {
let chapter: BookChapter
/// The book this chapter belongs to, resolved by slug used for the
/// pre-computed glossary. A @Query is safe here because the reader is
/// built lazily by `navigationDestination`: one instance, when opened.
@Query private var bookMatches: [Book]
private var book: Book? { bookMatches.first }
@Environment(DictionaryService.self) private var dictionary
@State private var speech = BookSpeechController()
@State private var selectedWord: WordAnnotation?
@State private var showEnglish = false
@State private var showVoicePicker = false
@State private var wasReadingBeforeTap = false
@State private var lookupCache: [String: WordAnnotation] = [:]
/// The book's pre-computed glossary, decoded once on appear.
@State private var glossary: [String: WordGloss] = [:]
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
init(chapter: BookChapter) {
self.chapter = chapter
let slug = chapter.bookSlug
_bookMatches = Query(filter: #Predicate<Book> { $0.slug == slug })
}
private var paragraphsES: [String] { chapter.paragraphsES() }
private var paragraphsEN: [String] { chapter.paragraphsEN() }
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 18) {
Text(chapter.title)
.font(.title2.bold())
.padding(.bottom, 4)
.id(-1)
ForEach(Array(paragraphsES.enumerated()), id: \.offset) { index, paragraph in
paragraphView(index: index, paragraph: paragraph)
.id(index)
}
}
.padding()
.adaptiveContainer(maxWidth: 800)
}
.onChange(of: speech.currentParagraphIndex) { _, newIndex in
guard let newIndex else { return }
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(newIndex, anchor: .center)
}
}
}
.navigationTitle("Chapter \(chapter.number)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
showVoicePicker = true
} label: {
Image(systemName: "waveform.circle")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel("Voice & speed")
Button {
toggleReadAloud()
} label: {
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.indigo)
}
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud")
Button {
withAnimation { showEnglish.toggle() }
} label: {
Image(systemName: showEnglish ? "character.book.closed.fill.he" : "character.book.closed")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel(showEnglish ? "Show Spanish" : "Show English")
}
}
.sheet(item: $selectedWord, onDismiss: handleSheetDismiss) { word in
WordDetailSheet(word: word)
.presentationDetents([.height(220)])
}
.sheet(isPresented: $showVoicePicker) {
BookVoicePickerSheet(voiceIdentifier: voiceBinding, rate: rateBinding)
}
.onAppear {
speech.voiceIdentifier = storedVoiceId.isEmpty ? nil : storedVoiceId
speech.rate = Float(storedRate)
if glossary.isEmpty {
glossary = book?.glossary() ?? [:]
}
}
.onDisappear {
speech.stop()
}
}
@ViewBuilder
private func paragraphView(index: Int, paragraph: String) -> some View {
if showEnglish {
Text(translation(for: index))
.font(.body)
.foregroundStyle(.secondary)
} else {
TappableParagraph(
text: paragraph,
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil
) { word in
handleTap(word: word, paragraph: paragraph)
}
}
}
private func translation(for index: Int) -> String {
guard index < paragraphsEN.count else { return "" }
let en = paragraphsEN[index]
return en.isEmpty ? "[translation unavailable]" : en
}
// MARK: - Read-along controls
private func toggleReadAloud() {
if speech.isReading {
speech.stop()
} 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)
}
}
private var voiceBinding: Binding<String?> {
Binding(
get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
set: { newValue in
storedVoiceId = newValue ?? ""
speech.voiceIdentifier = newValue
}
)
}
private var rateBinding: Binding<Float> {
Binding(
get: { Float(storedRate) },
set: { newValue in
storedRate = Double(newValue)
speech.rate = newValue
}
)
}
// MARK: - Word tap definition
private func handleTap(word: String, paragraph: String) {
let cleaned = cleanWord(word)
if cleaned.isEmpty { return }
// If reading aloud, pause immediately. Remember so we can resume when
// the user dismisses the definition sheet.
if speech.isReading, !speech.isPaused {
speech.pause()
wasReadingBeforeTap = true
}
// Fall-through chain, best source first. Whichever resource answers,
// the popup names it so a curated glossary hit reads differently from
// a best-effort on-device LLM guess.
if let cached = lookupCache[cleaned] {
selectedWord = cached
return
}
if let gloss = glossary[cleaned] {
let annotation = WordAnnotation(
word: cleaned,
baseForm: gloss.baseForm,
english: gloss.english,
partOfSpeech: gloss.partOfSpeech,
source: "Book glossary"
)
lookupCache[cleaned] = annotation
selectedWord = annotation
return
}
if let entry = dictionary.lookup(cleaned) {
let annotation = WordAnnotation(
word: cleaned,
baseForm: entry.baseForm,
english: entry.english,
partOfSpeech: entry.partOfSpeech,
source: "Dictionary"
)
lookupCache[cleaned] = annotation
selectedWord = annotation
return
}
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Looking up...", partOfSpeech: "")
Task {
do {
var annotation = try await WordLookup.lookup(word: cleaned, inContext: paragraph)
annotation.source = "AI guess"
lookupCache[cleaned] = annotation
selectedWord = annotation
} catch {
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Lookup unavailable", partOfSpeech: "")
}
}
}
private func handleSheetDismiss() {
guard wasReadingBeforeTap else { return }
wasReadingBeforeTap = false
speech.resume()
}
private func cleanWord(_ word: String) -> String {
word.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
}
}
// MARK: - Tappable paragraph
private struct TappableParagraph: View {
let text: String
let highlightedWordIndex: Int?
let onTap: (String) -> Void
var body: some View {
let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
FlowLayout(spacing: 0) {
ForEach(Array(words.enumerated()), id: \.offset) { idx, word in
WordButton(word: word, isHighlighted: idx == highlightedWordIndex, onTap: onTap)
}
}
.accessibilityElement(children: .combine)
}
}
private struct WordButton: View {
let word: String
let isHighlighted: Bool
let onTap: (String) -> Void
var body: some View {
Button {
onTap(word)
} label: {
Text(word + " ")
.font(.body)
.foregroundStyle(.primary)
.padding(.horizontal, isHighlighted ? 2 : 0)
.padding(.vertical, 1)
.background(
isHighlighted
? Color.yellow.opacity(0.35)
: Color.clear,
in: RoundedRectangle(cornerRadius: 4)
)
.animation(.easeInOut(duration: 0.15), value: isHighlighted)
}
.buttonStyle(.plain)
}
}
// MARK: - Flow layout
private struct FlowLayout: Layout {
var spacing: CGFloat = 0
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
var height: CGFloat = 0
for row in rows {
height += row.map { $0.height }.max() ?? 0
}
height += CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = computeRows(proposal: proposal, subviews: subviews)
var y = bounds.minY
var subviewIndex = 0
for row in rows {
var x = bounds.minX
let rowHeight = row.map { $0.height }.max() ?? 0
for size in row {
subviews[subviewIndex].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width
subviewIndex += 1
}
y += rowHeight + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
let maxWidth = proposal.width ?? .infinity
var rows: [[CGSize]] = [[]]
var currentWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentWidth + size.width > maxWidth && !rows[rows.count - 1].isEmpty {
rows.append([])
currentWidth = 0
}
rows[rows.count - 1].append(size)
currentWidth += size.width
}
return rows
}
}
// MARK: - Word detail sheet
private struct WordDetailSheet: View {
let word: WordAnnotation
var body: some View {
VStack(spacing: 16) {
HStack {
Text(word.word)
.font(.title2.bold())
Spacer()
if !word.partOfSpeech.isEmpty {
Text(word.partOfSpeech)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
Divider()
if word.english == "Looking up..." {
HStack(spacing: 8) {
ProgressView()
Text("Looking up word...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
} else {
VStack(alignment: .leading, spacing: 8) {
if !word.baseForm.isEmpty && word.baseForm != word.word {
HStack {
Text("Base form:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(word.baseForm)
.font(.subheadline.weight(.semibold))
.italic()
}
}
if !word.english.isEmpty {
HStack {
Text("English:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(word.english)
.font(.subheadline.weight(.semibold))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
if !word.source.isEmpty {
Text(sourceLabel)
.font(.caption2)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding()
}
private var sourceLabel: String {
word.source == "AI guess"
? "AI guess · on-device estimate, may be approximate"
: "Source: \(word.source)"
}
}
// MARK: - On-demand word lookup (matches StoryReaderView's WordLookup)
@MainActor
private enum WordLookup {
@Generable
struct WordInfo {
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
var baseForm: String
@Guide(description: "English translation")
var english: String
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, conjunction, article, pronoun, or other")
var partOfSpeech: String
}
static func lookup(word: String, inContext sentence: String) async throws -> WordAnnotation {
let session = LanguageModelSession(instructions: """
You are a Spanish dictionary. Given a word and the sentence it appears in, \
provide its base form, English translation, and part of speech.
""")
let response = try await session.respond(
to: "Word: \"\(word)\" in sentence: \"\(sentence)\"",
generating: WordInfo.self
)
let info = response.content
return WordAnnotation(
word: word,
baseForm: info.baseForm,
english: info.english,
partOfSpeech: info.partOfSpeech
)
}
}
@@ -0,0 +1,118 @@
import SwiftUI
import AVFoundation
import UIKit
/// Voice + speed picker shown from the book reader's toolbar. Lists Spanish
/// voices currently installed on the device grouped by quality, and offers a
/// shortcut to the iOS Settings app where the user can download premium voices
/// (no public deep-link to the Accessibility section exists, so we open the
/// app's own Settings page with a hint).
struct BookVoicePickerSheet: View {
@Binding var voiceIdentifier: String?
@Binding var rate: Float
@Environment(\.dismiss) private var dismiss
private struct VoiceGroup: Identifiable {
let id: String
let title: String
let voices: [AVSpeechSynthesisVoice]
}
private var groups: [VoiceGroup] {
let all = AVSpeechSynthesisVoice.speechVoices()
.filter { $0.language.hasPrefix("es") }
let buckets: [(String, AVSpeechSynthesisVoiceQuality)] = [
("Premium", .premium),
("Enhanced", .enhanced),
("Default", .default),
]
return buckets.compactMap { (title, quality) in
let voices = all
.filter { $0.quality == quality }
.sorted { lhs, rhs in
if lhs.language != rhs.language { return lhs.language < rhs.language }
return lhs.name < rhs.name
}
return voices.isEmpty ? nil : VoiceGroup(id: title, title: title, voices: voices)
}
}
var body: some View {
NavigationStack {
Form {
Section("Speed") {
Picker("Speed", selection: $rate) {
Text("Slow").tag(Float(0.40))
Text("Normal").tag(Float(0.50))
Text("Fast").tag(Float(0.55))
}
.pickerStyle(.segmented)
}
if groups.isEmpty {
Section {
ContentUnavailableView(
"No Spanish voices",
systemImage: "person.wave.2",
description: Text("Install a Spanish voice in Settings → Accessibility → Spoken Content → Voices.")
)
}
} else {
ForEach(groups) { group in
Section(group.title) {
ForEach(group.voices, id: \.identifier) { voice in
voiceRow(voice)
}
}
}
}
Section {
Button {
openSettings()
} label: {
Label("Download more voices…", systemImage: "arrow.down.circle")
}
} footer: {
Text("Opens Settings. Navigate to Accessibility → Spoken Content → Voices → Spanish to install premium or enhanced voices.")
}
}
.navigationTitle("Read aloud")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
private func voiceRow(_ voice: AVSpeechSynthesisVoice) -> some View {
Button {
voiceIdentifier = voice.identifier
} label: {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(voice.name)
.font(.body)
.foregroundStyle(.primary)
Text(voice.language)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if voice.identifier == voiceIdentifier {
Image(systemName: "checkmark")
.foregroundStyle(.indigo)
}
}
}
.tint(.primary)
}
private func openSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
}
@@ -1,16 +1,27 @@
import SwiftUI
import SharedModels
import SwiftData
import FoundationModels
@Generable
private struct ChatWordInfo {
@Guide(description: "Dictionary base form") var baseForm: String
@Guide(description: "English translation") var english: String
@Guide(description: "Part of speech") var partOfSpeech: String
}
struct ChatView: View {
let conversation: Conversation
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(DictionaryService.self) private var dictionary
@State private var service = ConversationService()
@State private var messages: [ChatMessage] = []
@State private var inputText = ""
@State private var errorMessage: String?
@State private var hasStarted = false
@State private var selectedWord: WordAnnotation?
@State private var lookupCache: [String: WordAnnotation] = [:]
private var cloudContext: ModelContext { cloudModelContextProvider() }
@@ -21,8 +32,10 @@ struct ChatView: View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(messages) { message in
ChatBubble(message: message)
.id(message.id)
ChatBubble(message: message, dictionary: dictionary, lookupCache: $lookupCache) { word in
selectedWord = word
}
.id(message.id)
}
if service.isResponding {
@@ -68,6 +81,10 @@ struct ChatView: View {
}
.navigationTitle(conversation.scenario)
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedWord) { word in
ChatWordDetailSheet(word: word)
.presentationDetents([.height(200)])
}
.alert("Error", isPresented: .init(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
@@ -102,6 +119,7 @@ struct ChatView: View {
messages = conversation.decodedMessages
inputText = ""
try? cloudContext.save()
ReviewStore.recordActivity(context: cloudContext)
Task {
do {
@@ -121,6 +139,9 @@ struct ChatView: View {
private struct ChatBubble: View {
let message: ChatMessage
let dictionary: DictionaryService
@Binding var lookupCache: [String: WordAnnotation]
let onWordTap: (WordAnnotation) -> Void
private var isUser: Bool { message.role == "user" }
@@ -129,11 +150,15 @@ private struct ChatBubble: View {
if isUser { Spacer(minLength: 60) }
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
Text(message.content)
.font(.body)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(isUser ? AnyShapeStyle(.green.opacity(0.2)) : AnyShapeStyle(.fill.quaternary), in: RoundedRectangle(cornerRadius: 16))
if isUser {
Text(message.content)
.font(.body)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.green.opacity(0.2), in: RoundedRectangle(cornerRadius: 16))
} else {
tappableBubble
}
if let correction = message.correction, !correction.isEmpty {
Text(correction)
@@ -147,4 +172,179 @@ private struct ChatBubble: View {
}
.padding(.horizontal)
}
private var tappableBubble: some View {
let words = message.content.components(separatedBy: " ")
return ChatFlowLayout(spacing: 0) {
ForEach(Array(words.enumerated()), id: \.offset) { _, word in
ChatWordButton(word: word, dictionary: dictionary, cache: lookupCache) { annotation in
if annotation.english.isEmpty {
lookupWordAsync(annotation.word)
} else {
onWordTap(annotation)
}
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 16))
}
private func lookupWordAsync(_ word: String) {
// Try dictionary first
if let entry = dictionary.lookup(word) {
let annotation = WordAnnotation(word: word, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech)
lookupCache[word] = annotation
onWordTap(annotation)
return
}
// Show loading then AI lookup
onWordTap(WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: ""))
Task {
do {
let session = LanguageModelSession(instructions: "You are a Spanish dictionary. Provide base form, English translation, and part of speech.")
let response = try await session.respond(to: "Word: \"\(word)\"", generating: ChatWordInfo.self)
let info = response.content
let annotation = WordAnnotation(word: word, baseForm: info.baseForm, english: info.english, partOfSpeech: info.partOfSpeech)
lookupCache[word] = annotation
onWordTap(annotation)
} catch {
onWordTap(WordAnnotation(word: word, baseForm: word, english: "Lookup unavailable", partOfSpeech: ""))
}
}
}
}
// MARK: - Chat Word Button
private struct ChatWordButton: View {
let word: String
let dictionary: DictionaryService
let cache: [String: WordAnnotation]
let onTap: (WordAnnotation) -> Void
private var cleaned: String {
word.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
}
private var annotation: WordAnnotation? {
if let cached = cache[cleaned] { return cached }
if let entry = dictionary.lookup(cleaned) {
return WordAnnotation(word: cleaned, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech)
}
return nil
}
var body: some View {
Button {
onTap(annotation ?? WordAnnotation(word: cleaned, baseForm: cleaned, english: "", partOfSpeech: ""))
} label: {
Text(word + " ")
.font(.body)
.foregroundStyle(.primary)
.underline(annotation != nil, color: .teal.opacity(0.3))
}
.buttonStyle(.plain)
}
}
// MARK: - Word Detail Sheet
private struct ChatWordDetailSheet: View {
let word: WordAnnotation
var body: some View {
VStack(spacing: 16) {
HStack {
Text(word.word)
.font(.title2.bold())
Spacer()
if !word.partOfSpeech.isEmpty {
Text(word.partOfSpeech)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
Divider()
if word.english == "Looking up..." {
HStack(spacing: 8) {
ProgressView()
Text("Looking up word...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
} else {
VStack(alignment: .leading, spacing: 8) {
if !word.baseForm.isEmpty && word.baseForm != word.word {
HStack {
Text("Base form:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(word.baseForm)
.font(.subheadline.weight(.semibold))
.italic()
}
}
if !word.english.isEmpty {
HStack {
Text("English:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(word.english)
.font(.subheadline.weight(.semibold))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
}
.padding()
}
}
// MARK: - Chat Flow Layout
private struct ChatFlowLayout: Layout {
var spacing: CGFloat = 0
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
var height: CGFloat = 0
for row in rows { height += row.map { $0.height }.max() ?? 0 }
height += CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = computeRows(proposal: proposal, subviews: subviews)
var y = bounds.minY; var idx = 0
for row in rows {
var x = bounds.minX; let rh = row.map { $0.height }.max() ?? 0
for size in row {
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width; idx += 1
}
y += rh + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
let mw = proposal.width ?? .infinity; var rows: [[CGSize]] = [[]]; var cw: CGFloat = 0
for sv in subviews {
let s = sv.sizeThatFits(.unspecified)
if cw + s.width > mw && !rows[rows.count - 1].isEmpty { rows.append([]); cw = 0 }
rows[rows.count - 1].append(s); cw += s.width
}
return rows
}
}
@@ -98,6 +98,7 @@ struct ClozeView: View {
currentIndex += 1
selectedOption = nil
} else {
ReviewStore.recordActivity(context: cloudContext)
withAnimation { isFinished = true }
}
} label: {
@@ -20,6 +20,7 @@ struct FullTableView: View {
@State private var useHandwriting = false
@State private var sessionCount = 0
@State private var sessionCorrect = 0
@State private var noEligibleVerbs = false
// Handwriting state per field
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
@@ -53,35 +54,39 @@ struct FullTableView: View {
var body: some View {
ScrollView {
VStack(spacing: 32) {
// Header
if let verb = currentVerb, let tense = currentTense {
headerSection(verb: verb, tense: tense)
}
// Input mode toggle
HStack {
Picker("Input", selection: $useHandwriting) {
Label("Keyboard", systemImage: "keyboard").tag(false)
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
if noEligibleVerbs {
emptyPoolError
} else {
VStack(spacing: 32) {
// Header
if let verb = currentVerb, let tense = currentTense {
headerSection(verb: verb, tense: tense)
}
// Input mode toggle
HStack {
Picker("Input", selection: $useHandwriting) {
Label("Keyboard", systemImage: "keyboard").tag(false)
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
// Input fields
inputSection
// Check / Next button
actionButton
// Score
if sessionCount > 0 {
scoreSection
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
// Input fields
inputSection
// Check / Next button
actionButton
// Score
if sessionCount > 0 {
scoreSection
}
.padding()
.adaptiveContainer()
}
.padding()
.adaptiveContainer()
}
.navigationTitle("Full Table")
.navigationBarTitleDisplayMode(.inline)
@@ -91,6 +96,22 @@ struct FullTableView: View {
}
}
// MARK: - Empty pool error
private var emptyPoolError: some View {
VStack(spacing: 16) {
ContentUnavailableView(
"No regular verbs available",
systemImage: "exclamationmark.triangle",
description: Text(
"None of the selected tenses have any fully-regular verbs in the current settings. Enable more tenses, or turn off the Reflexive-only toggle in Settings."
)
)
}
.padding()
.adaptiveContainer()
}
// MARK: - Header
private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
@@ -243,15 +264,27 @@ struct FullTableView: View {
results = Array(repeating: nil, count: 6)
correctForms = []
drawings = Array(repeating: PKDrawing(), count: 6)
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
guard let prompt = service.randomFullTablePrompt() else {
let service = PracticeSessionService(
localContext: modelContext,
cloudContext: cloudModelContext,
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
)
guard let prompt = service.randomFullTablePrompt(
previousTenseId: currentTense?.id,
previousEnding: currentVerb?.ending
) else {
// Genuinely no eligible (verb, tense) combo. Surface a clear error
// instead of a blank screen the previous behaviour silently
// rendered an empty header and inputs.
currentVerb = nil
currentTense = nil
userAnswers = Array(repeating: "", count: 6)
focusedField = nil
noEligibleVerbs = true
return
}
noEligibleVerbs = false
currentVerb = prompt.verb
currentTense = prompt.tenseInfo
correctForms = prompt.forms
@@ -312,7 +345,11 @@ struct FullTableView: View {
if allCorrect { sessionCorrect += 1 }
if let verb = currentVerb, let tense = currentTense {
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
let service = PracticeSessionService(
localContext: modelContext,
cloudContext: cloudModelContext,
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
)
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
}
@@ -4,6 +4,8 @@ import SwiftData
struct ListeningView: View {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var pronunciation = PronunciationService()
@State private var speechService = SpeechService()
@@ -122,6 +124,7 @@ struct ListeningView: View {
Button {
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
if result.score >= 0.7 { correctCount += 1 }
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isRevealed = true }
} label: {
Text("Check")
@@ -164,6 +167,7 @@ struct ListeningView: View {
score = result.score
wordMatches = result.matches
if result.score >= 0.7 { correctCount += 1 }
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isRevealed = true }
} else {
pronunciation.startRecording()
@@ -1,9 +1,16 @@
import SwiftUI
import SwiftData
import SharedModels
struct LyricsReaderView: View {
let song: SavedSong
@Environment(DictionaryService.self) private var dictionary
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var selectedWord: LyricsWordLookup?
@State private var lookupCache: [String: LyricsWordLookup] = [:]
var body: some View {
ScrollView {
VStack(spacing: 20) {
@@ -15,6 +22,10 @@ struct LyricsReaderView: View {
}
.navigationTitle(song.title)
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedWord) { word in
LyricsWordDetailSheet(word: word)
.presentationDetents([.height(260)])
}
}
// MARK: - Header
@@ -56,15 +67,6 @@ struct LyricsReaderView: View {
let spanishLines = song.lyricsES.components(separatedBy: "\n")
let englishLines = song.lyricsEN.components(separatedBy: "\n")
let lineCount = max(spanishLines.count, englishLines.count)
let _ = {
print("[LyricsReader] ES lines: \(spanishLines.count), EN lines: \(englishLines.count), rendering: \(lineCount)")
for i in 0..<min(15, lineCount) {
let es = i < spanishLines.count ? spanishLines[i] : "(none)"
let en = i < englishLines.count ? englishLines[i] : "(none)"
print(" [\(i)] ES: \(es.isEmpty ? "(blank)" : es)")
print(" EN: \(en.isEmpty ? "(blank)" : en)")
}
}()
return VStack(alignment: .leading, spacing: 0) {
ForEach(0..<lineCount, id: \.self) { index in
@@ -78,8 +80,7 @@ struct LyricsReaderView: View {
} else {
VStack(alignment: .leading, spacing: 2) {
if !es.isEmpty {
Text(es)
.font(.body.weight(.medium))
spanishLine(es)
}
if !en.isEmpty {
Text(en)
@@ -94,4 +95,184 @@ struct LyricsReaderView: View {
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
private func spanishLine(_ line: String) -> some View {
let tokens = line.components(separatedBy: " ")
return LyricsFlowLayout(spacing: 0) {
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
ReviewStore.recordActivity(context: cloudModelContext)
selectedWord = word
}
}
}
}
// MARK: - Lookup
private func makeLookup(for rawToken: String) -> LyricsWordLookup? {
let cleaned = rawToken.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
if let cached = lookupCache[cleaned] { return cached }
guard let entry = dictionary.lookup(cleaned) else { return nil }
let displayWord = rawToken
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
let tenseDisplay = entry.tenseId.flatMap { TenseInfo.find($0)?.english }
let lookup = LyricsWordLookup(
word: displayWord.isEmpty ? entry.word : displayWord,
baseForm: entry.baseForm,
english: entry.english,
partOfSpeech: entry.partOfSpeech,
tenseDisplay: tenseDisplay,
person: entry.person
)
lookupCache[cleaned] = lookup
return lookup
}
}
// MARK: - Word Lookup Model
private struct LyricsWordLookup: Identifiable, Hashable {
let word: String
let baseForm: String
let english: String
let partOfSpeech: String
let tenseDisplay: String?
let person: String?
var id: String { word }
}
// MARK: - Word View
private struct LyricsWordView: View {
let token: String
let lookup: LyricsWordLookup?
let onLookup: (LyricsWordLookup) -> Void
var body: some View {
Text(token + " ")
.font(.body.weight(.medium))
.foregroundStyle(.primary)
.underline(lookup != nil, color: .teal.opacity(0.35))
.contentShape(Rectangle())
.onLongPressGesture(minimumDuration: 0.35) {
if let lookup {
onLookup(lookup)
}
}
}
}
// MARK: - Detail Sheet
private struct LyricsWordDetailSheet: View {
let word: LyricsWordLookup
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text(word.word)
.font(.title2.bold())
Spacer()
if !word.partOfSpeech.isEmpty {
Text(word.partOfSpeech)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
Divider()
VStack(alignment: .leading, spacing: 10) {
if !word.baseForm.isEmpty && word.baseForm.lowercased() != word.word.lowercased() {
detailRow(label: "Base form", value: word.baseForm, italic: true)
}
if !word.english.isEmpty {
detailRow(label: "English", value: word.english)
}
if let tenseDisplay = word.tenseDisplay {
let personSuffix = (word.person?.isEmpty == false) ? " · \(word.person!)" : ""
detailRow(label: "Tense", value: tenseDisplay + personSuffix)
}
}
Spacer(minLength: 0)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func detailRow(label: String, value: String, italic: Bool = false) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(label):")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(width: 86, alignment: .leading)
Text(value)
.font(.subheadline.weight(.semibold))
.italic(italic)
Spacer(minLength: 0)
}
}
}
// MARK: - Flow Layout
private struct LyricsFlowLayout: Layout {
var spacing: CGFloat = 0
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
var height: CGFloat = 0
for row in rows { height += row.map { $0.height }.max() ?? 0 }
height += CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = computeRows(proposal: proposal, subviews: subviews)
var y = bounds.minY
var idx = 0
for row in rows {
var x = bounds.minX
let rh = row.map { $0.height }.max() ?? 0
for size in row {
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width
idx += 1
}
y += rh + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
let mw = proposal.width ?? .infinity
var rows: [[CGSize]] = [[]]
var cw: CGFloat = 0
for sv in subviews {
let s = sv.sizeThatFits(.unspecified)
if cw + s.width > mw && !rows[rows.count - 1].isEmpty {
rows.append([])
cw = 0
}
rows[rows.count - 1].append(s)
cw += s.width
}
return rows
}
}
@@ -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
}
}
+417 -161
View File
@@ -9,6 +9,12 @@ struct PracticeView: View {
@State private var speechService = SpeechService()
@State private var isPracticing = false
@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() }
@@ -21,12 +27,29 @@ struct PracticeView: View {
practiceHomeView
}
}
// Book navigation is value-based and declared once here, at the
// stack root. Eager `NavigationLink { destination }` forms inside
// the List/LazyVStack of the book screens caused an infinite
// render loop; value-based links build destinations lazily.
.navigationDestination(for: BooksRoute.self) { _ in
BookLibraryView()
}
.navigationDestination(for: Book.self) { book in
BookChapterListView(book: book)
}
.navigationDestination(for: BookChapter.self) { chapter in
BookReaderView(chapter: chapter)
}
.navigationTitle("Practice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadProgress)
.onAppear {
loadProgress()
refreshLexemeDueCounts()
}
.onChange(of: isPracticing) { _, practicing in
if !practicing {
loadProgress()
refreshLexemeDueCounts()
}
}
.toolbar {
@@ -74,12 +97,10 @@ struct PracticeView: View {
.padding(.top, 8)
}
// Mode selection
VStack(spacing: 12) {
Text("Choose a Mode")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
// === Section: Conjugation ===
sectionHeader("Conjugation")
VStack(spacing: 12) {
ForEach(PracticeMode.allCases) { mode in
ModeButton(mode: mode) {
viewModel.practiceMode = mode
@@ -98,6 +119,23 @@ struct PracticeView: View {
}
.padding(.horizontal)
conjugationFocusButtons
// === Section: Vocabulary ===
sectionHeader("Vocabulary")
vocabSection
// === Section: Nouns ===
sectionHeader("Nouns")
nounsSection
// === Section: Adjectives ===
sectionHeader("Adjectives")
adjectivesSection
// === Section: Reading ===
sectionHeader("Reading")
// Lyrics
NavigationLink {
LyricsLibraryView()
@@ -253,166 +291,33 @@ struct PracticeView: View {
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Quick Actions
VStack(spacing: 12) {
Text("Quick Actions")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
// Books
NavigationLink(value: BooksRoute.library) {
HStack(spacing: 14) {
Image(systemName: "books.vertical.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.indigo)
// Vocab review
NavigationLink {
VocabReviewView()
} label: {
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.teal)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Vocab Review")
.font(.subheadline.weight(.semibold))
Text("Review due vocabulary cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
if dueCount > 0 {
Text("\(dueCount)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.teal, in: Capsule())
}
Image(systemName: "chevron.right")
VStack(alignment: .leading, spacing: 2) {
Text("Books")
.font(.subheadline.weight(.semibold))
Text("Read full-length books with tap-to-define")
.font(.caption)
.foregroundStyle(.tertiary)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Common tenses focus
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .commonTenses
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { isPracticing = true }
} label: {
HStack(spacing: 14) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.orange)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Common Tenses")
.font(.subheadline.weight(.semibold))
Text("Practice the 6 most essential tenses")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Weak verbs focus
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .weakVerbs
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { isPracticing = true }
} label: {
HStack(spacing: 14) {
Image(systemName: "exclamationmark.triangle")
.font(.title3)
.foregroundStyle(.red)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Weak Verbs")
.font(.subheadline.weight(.semibold))
Text("Focus on verbs you struggle with")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Irregularity drills
Menu {
Button("Spelling Changes (c→qu, z→c, ...)") {
startIrregularityDrill(.spelling)
}
Button("Stem Changes (o→ue, e→ie, ...)") {
startIrregularityDrill(.stemChange)
}
Button("Unique Irregulars (ser, ir, ...)") {
startIrregularityDrill(.uniqueIrregular)
}
} label: {
HStack(spacing: 14) {
Image(systemName: "wand.and.stars")
.font(.title3)
.foregroundStyle(.purple)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Irregularity Drills")
.font(.subheadline.weight(.semibold))
Text("Practice by irregularity type")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Session stats summary
@@ -442,6 +347,352 @@ struct PracticeView: View {
}
}
// MARK: - Section header
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
// MARK: - Conjugation focus buttons (Common Tenses / Weak Verbs / Irregularity)
private var conjugationFocusButtons: some View {
VStack(spacing: 12) {
// Common Tenses
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .commonTenses
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
withAnimation { isPracticing = true }
} label: {
practiceRowLabel(icon: "star.fill", color: .orange,
title: "Common Tenses",
subtitle: "Practice the 6 most essential tenses")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Weak Verbs
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .weakVerbs
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
withAnimation { isPracticing = true }
} label: {
practiceRowLabel(icon: "exclamationmark.triangle", color: .red,
title: "Weak Verbs",
subtitle: "Focus on verbs you struggle with")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Irregularity drills
Menu {
Button("Spelling Changes (c→qu, z→c, ...)") { startIrregularityDrill(.spelling) }
Button("Stem Changes (o→ue, e→ie, ...)") { startIrregularityDrill(.stemChange) }
Button("Unique Irregulars (ser, ir, ...)") { startIrregularityDrill(.uniqueIrregular) }
} label: {
practiceRowLabel(icon: "wand.and.stars", color: .purple,
title: "Irregularity Drills",
subtitle: "Practice by irregularity type")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
}
// MARK: - Vocabulary section
private var vocabSection: some View {
VStack(spacing: 12) {
// Vocab Flashcards (verb pool, filtered by Settings levels)
NavigationLink {
VocabFlashcardPracticeView()
} label: {
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple,
title: "Vocab Flashcards",
subtitle: "Verb meaning → infinitive recall")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Vocab Multiple Choice (same verb pool)
NavigationLink {
VocabMultipleChoicePracticeView()
} label: {
practiceRowLabel(icon: "checklist", color: .purple,
title: "Vocab Multiple Choice",
subtitle: "Pick the Spanish infinitive from 4 options")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Review Learned consolidation cram over already-studied verbs
NavigationLink {
VocabFlashcardPracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
title: "Review Learned — Flashcards",
subtitle: "Re-review verbs you've studied — schedule unchanged")
}
.tint(.primary)
.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)
NavigationLink {
VocabReviewView()
} label: {
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.teal)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Vocab Review")
.font(.subheadline.weight(.semibold))
Text("Review due vocabulary cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
if dueCount > 0 {
Text("\(dueCount)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.teal, in: Capsule())
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
}
// 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 {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
// MARK: - Practice Session View
@ViewBuilder
@@ -560,6 +811,11 @@ extension PracticeView {
withAnimation { isPracticing = true }
}
private func refreshLexemeDueCounts() {
nounDueCount = NounReviewView.dueCount(context: cloudModelContext)
adjectiveDueCount = AdjectiveReviewView.dueCount(context: cloudModelContext)
}
private func loadProgress() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
userProgress = progress
@@ -4,6 +4,8 @@ import SwiftData
struct SentenceBuilderView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var currentCard: VocabCard?
@State private var exampleIndex: Int = 0
@@ -316,6 +318,7 @@ struct SentenceBuilderView: View {
if isCorrect {
sessionCorrect += 1
}
ReviewStore.recordActivity(context: cloudModelContext)
}
private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
@@ -1,9 +1,13 @@
import SwiftUI
import SwiftData
import SharedModels
struct StoryQuizView: View {
let story: Story
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var currentIndex = 0
@State private var selectedOption: Int?
@State private var correctCount = 0
@@ -85,6 +89,7 @@ struct StoryQuizView: View {
currentIndex += 1
selectedOption = nil
} else {
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isFinished = true }
}
} label: {
@@ -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)"
}
}
@@ -0,0 +1,518 @@
import SwiftUI
import SharedModels
import SwiftData
/// English-first verb flashcards with two modes, switched from the toolbar:
///
/// - **Quiz** the SRS path. `VocabSessionQueue` learning-step queue:
/// tap to reveal, rate Again/Hard/Good/Easy, cards requeue, graduation
/// feeds the long-term `VerbReviewStore` schedule.
/// - **Learn** no-pressure browsing. Both sides shown at once (English +
/// Spanish + example), Next/Previous step through the same session pool
/// on a loop. No rating, no SRS side effects.
/// Which pool a flashcard session draws from.
enum VocabSessionKind {
/// Due-first + new verbs, capped the standard SRS session. Ratings
/// update the long-term schedule.
case standard
/// Verbs already studied at least once, most-recent first, uncapped a
/// consolidation cram. Ratings drive the in-session queue only and do NOT
/// reschedule (the long-term SM-2 due dates are left untouched).
case reviewLearned
}
struct VocabFlashcardPracticeView: View {
enum Mode: String { case quiz, learn }
var kind: VocabSessionKind = .standard
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss
@AppStorage("vocabFlashcardMode") private var modeRaw: String = Mode.quiz.rawValue
@State private var session: VocabSessionQueue?
@State private var learnIndex: Int = 0
@State private var revealed: Bool = false
@State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingVerbIds: Set<Int> = []
@State private var speech = SpeechService()
private var cloudContext: ModelContext { cloudModelContextProvider() }
/// The session's un-graduated verbs, derived live from the quiz queue so
/// Learn mode walks exactly what's left to learn it stays in sync as
/// quiz mode graduates cards rather than browsing a stale frozen pool.
private var sessionVerbs: [Verb] {
session?.queue.map(\.verb) ?? []
}
private var mode: Mode {
get { Mode(rawValue: modeRaw) ?? .quiz }
nonmutating set { modeRaw = newValue.rawValue }
}
private var currentVerb: Verb? {
switch mode {
case .quiz:
return session?.current?.verb
case .learn:
guard !sessionVerbs.isEmpty else { return nil }
return sessionVerbs[learnIndex % sessionVerbs.count]
}
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
headerBar
switch mode {
case .quiz:
quizContent
case .learn:
learnContent
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Flashcards")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Picker("Mode", selection: Binding(
get: { mode },
set: { mode = $0 }
)) {
Label("Quiz (SRS)", systemImage: "checklist").tag(Mode.quiz)
Label("Learn", systemImage: "book").tag(Mode.learn)
}
} label: {
Label(
mode == .learn ? "Learn" : "Quiz",
systemImage: mode == .learn ? "book" : "checklist"
)
}
}
}
.onAppear(perform: loadIfNeeded)
.onChange(of: modeRaw) { _, _ in
revealed = false
primeExampleForCurrent()
}
.animation(.smooth, value: revealed)
.animation(.smooth, value: currentVerb?.id)
}
// MARK: - Header
@ViewBuilder
private var headerBar: some View {
VStack(spacing: 6) {
switch mode {
case .quiz:
ProgressView(value: session?.progress ?? 0)
.tint(.purple)
Text(quizProgressLabel)
.font(.caption)
.foregroundStyle(.secondary)
case .learn:
if sessionVerbs.isEmpty {
Text(emptyPoolMessage)
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("\(learnIndex % sessionVerbs.count + 1) of \(sessionVerbs.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
}
if kind == .reviewLearned {
Text("Practice pass — your review schedule won't change.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
private var emptyPoolMessage: String {
switch kind {
case .standard:
return "No verbs match the levels enabled in Settings"
case .reviewLearned:
return "Nothing studied yet — finish a Vocab Flashcards session first"
}
}
private var quizProgressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Quiz mode
@ViewBuilder
private var quizContent: some View {
if let verb = currentVerb {
Text(verb.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if revealed {
quizRevealed(verb)
} else {
tapToReveal
}
} else {
completionView
}
}
private var tapToReveal: some View {
VStack(spacing: 8) {
Image(systemName: "hand.tap")
.font(.title)
.foregroundStyle(.secondary)
Text("Tap to reveal")
.font(.headline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.frame(minHeight: 200)
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.smooth) { revealed = true }
}
}
private func quizRevealed(_ verb: Verb) -> some View {
VStack(spacing: 18) {
spanishRow(verb)
exampleBlock(for: verb)
ratingButtons
}
}
private var ratingButtons: some View {
VStack(spacing: 10) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, 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: VocabSessionQueue.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(completionTitle)
.font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button {
studyAgain()
} label: {
Label(
kind == .reviewLearned ? "Study Again" : "Next Set",
systemImage: kind == .reviewLearned ? "arrow.clockwise" : "arrow.right"
)
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
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
switch kind {
case .standard:
return learned > 0 ? "Session Complete" : "Nothing Due"
case .reviewLearned:
return learned > 0 ? "Review Complete" : "Nothing to Review"
}
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
let noun = kind == .reviewLearned ? "reviewed" : "learned"
return "\(learned) verb\(learned == 1 ? "" : "s") \(noun)"
}
switch kind {
case .standard:
return "No verbs are due right now. Study Again to review anyway."
case .reviewLearned:
return "Finish a Vocab Flashcards session first, then come back to consolidate."
}
}
// MARK: - Learn mode
@ViewBuilder
private var learnContent: some View {
if let verb = currentVerb {
Text(verb.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
spanishRow(verb)
exampleBlock(for: verb)
HStack(spacing: 12) {
Button {
learnStep(-1)
} label: {
Label("Previous", systemImage: "chevron.left")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(.secondary)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
Button {
learnStep(1)
} label: {
Label("Next", systemImage: "chevron.right")
.labelStyle(.titleAndIcon)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(.purple)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
} else {
ContentUnavailableView(
"Nothing to Learn",
systemImage: "book",
description: Text("Enable some verb levels in Settings.")
)
.padding(.top, 40)
}
}
private func learnStep(_ delta: Int) {
guard !sessionVerbs.isEmpty else { return }
let count = sessionVerbs.count
learnIndex = ((learnIndex + delta) % count + count) % count
primeExampleForCurrent()
}
// MARK: - Shared card pieces
private func spanishRow(_ verb: Verb) -> some View {
HStack(spacing: 12) {
Text(verb.infinitive)
.font(.title.weight(.semibold))
.multilineTextAlignment(.center)
Button {
speech.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title3)
.padding(10)
}
.glassEffect(in: .circle)
.accessibilityLabel("Say it out loud")
}
}
@ViewBuilder
private func exampleBlock(for verb: Verb) -> some View {
if let example = exampleByVerbId[verb.id] {
VStack(alignment: .leading, spacing: 4) {
Text(example.spanish).font(.subheadline).italic()
Text(example.english).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} else if generatingVerbIds.contains(verb.id) {
HStack(spacing: 8) {
ProgressView()
Text("Generating example…")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Logic
private func loadIfNeeded() {
guard session == nil else { return }
switch kind {
case .standard:
loadStandardSession()
case .reviewLearned:
let verbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: verbs)
}
primeExampleForCurrent()
}
/// Resume the persisted, cross-device study group if one is active;
/// otherwise start a fresh group and persist it.
private func loadStandardSession() {
let store = VocabStudyGroupStore(context: cloudContext)
if let group = store.activeGroup() {
let stored = group.entries
if !stored.isEmpty {
let byId = verbsByID(Set(stored.map(\.verbId)))
let entries: [(verb: Verb, state: VocabSessionQueue.CardState)] = stored.compactMap { e in
guard let verb = byId[e.verbId] else { return nil }
return (verb, VocabSessionQueue.CardState(rawValue: e.state) ?? .new)
}
// Resume only if EVERY stored verb resolved. A partial resume
// would desync learnedCount from a shrunken queue and then
// persist that loss fall through to a fresh rebuild instead.
if entries.count == stored.count {
session = VocabSessionQueue(entries: entries, learnedCount: group.learnedCount)
return
}
}
}
// No active group (or it couldn't be fully resolved) start fresh and
// persist immediately so closing the app right away still resumes this set.
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: verbs)
persistGroup()
}
private func verbsByID(_ ids: Set<Int>) -> [Int: Verb] {
let all = (try? localContext.fetch(FetchDescriptor<Verb>())) ?? []
var map: [Int: Verb] = [:]
for verb in all where ids.contains(verb.id) { map[verb.id] = verb }
return map
}
/// Write the standard session's progress to the cloud-synced study group,
/// or clear the group when the set is fully learned.
private func persistGroup() {
guard kind == .standard, let session else { return }
let store = VocabStudyGroupStore(context: cloudContext)
if session.isComplete {
store.clear()
} else {
let entries = session.snapshot().map {
StoredVocabEntry(verbId: $0.verbId, state: $0.state.rawValue)
}
store.persist(entries: entries, learnedCount: session.learnedCount)
}
}
private func studyAgain() {
switch kind {
case .standard:
// Clear the finished group explicitly before building a fresh one,
// so a fresh set is never appended onto a stale group record.
VocabStudyGroupStore(context: cloudContext).clear()
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: verbs)
persistGroup()
case .reviewLearned:
session?.restart()
}
learnIndex = 0
revealed = false
primeExampleForCurrent()
}
private func answer(_ rating: VocabSessionQueue.Rating) {
guard let verbId = currentVerb?.id else { return }
let graduation = session?.answer(rating) ?? nil
// Review Learned is a cram pass graduation drives the in-session
// queue only; the long-term SM-2 schedule is left untouched.
if let graduation, kind == .standard {
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
}
persistGroup()
withAnimation(.smooth) { revealed = false }
primeExampleForCurrent()
}
private func primeExampleForCurrent() {
guard let verb = currentVerb else { return }
let verbId = verb.id
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
if let cached = exampleCache.examples(for: verbId)?.first {
exampleByVerbId[verbId] = cached
return
}
guard VerbExampleGenerator.isAvailable else { return }
generatingVerbIds.insert(verbId)
let infinitive = verb.infinitive
let english = verb.english
let formsByTense = ReferenceStore(context: localContext)
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
Task {
do {
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
tenseIds: VocabExampleTenseIds.canonical,
formsByTense: formsByTense
)
exampleCache.setExamples(examples, for: verbId)
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
if let pick, currentVerb?.id == verbId {
exampleByVerbId[verbId] = pick
}
} catch {
// Silent the example block just stays hidden.
}
generatingVerbIds.remove(verbId)
}
}
}
@@ -0,0 +1,312 @@
import SwiftUI
import SharedModels
import SwiftData
/// English-first verb multiple choice, driven by `VocabSessionQueue`. 4 options
/// (1 correct + 3 random distractors from the session pool). After answering:
/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS
/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates.
struct VocabMultipleChoicePracticeView: View {
var kind: VocabSessionKind = .standard
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss
@State private var session: VocabSessionQueue?
@State private var distractorPool: [Verb] = []
@State private var options: [Verb] = []
@State private var selectedOption: Verb? = nil
@State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingVerbIds: Set<Int> = []
@State private var speech = SpeechService()
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentVerb: Verb? { session?.current?.verb }
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let verb = currentVerb {
questionBody(verb)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Multiple Choice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id)
.animation(.smooth, value: currentVerb?.id)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0)
.tint(.purple)
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(_ verb: Verb) -> some View {
Text(verb.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if selectedOption == nil {
optionGrid
} else {
revealedContent(verb)
}
}
private var optionGrid: some View {
VStack(spacing: 10) {
ForEach(options, id: \.id) { option in
Button {
selectedOption = option
} label: {
Text(option.infinitive)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.tint(.primary)
}
}
}
private func revealedContent(_ verb: Verb) -> some View {
VStack(spacing: 16) {
answerFeedback(verb)
exampleBlock(for: verb)
ratingButtons
}
}
private func answerFeedback(_ verb: Verb) -> some View {
let correct = (selectedOption?.id == verb.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)
HStack(spacing: 10) {
Text(verb.infinitive)
.font(.title2.weight(.semibold))
Button {
speech.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.body)
.padding(8)
}
.glassEffect(in: .circle)
.accessibilityLabel("Say it out loud")
}
.padding(.top, 4)
}
}
@ViewBuilder
private func exampleBlock(for verb: Verb) -> some View {
if let example = exampleByVerbId[verb.id] {
VStack(alignment: .leading, spacing: 4) {
Text(example.spanish).font(.subheadline).italic()
Text(example.english).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} else if generatingVerbIds.contains(verb.id) {
HStack(spacing: 8) {
ProgressView()
Text("Generating example…")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.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: VocabSessionQueue.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(.purple)
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) 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."
}
}
// MARK: - Logic
private func loadIfNeeded() {
guard session == nil else { return }
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
session = VocabSessionQueue(verbs: verbs)
prepareOptions()
primeExampleForCurrent()
}
private func studyAgain() {
session?.restart()
selectedOption = nil
prepareOptions()
primeExampleForCurrent()
}
private func prepareOptions() {
guard let verb = currentVerb else { options = []; return }
let candidates = distractorPool.filter { $0.id != verb.id }
let distractors = Array(candidates.shuffled().prefix(3))
options = ([verb] + distractors).shuffled()
}
private func answer(_ rating: VocabSessionQueue.Rating) {
guard let verbId = currentVerb?.id else { return }
let graduation = session?.answer(rating) ?? nil
// 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)
}
selectedOption = nil
prepareOptions()
primeExampleForCurrent()
}
private func primeExampleForCurrent() {
guard let verb = currentVerb else { return }
let verbId = verb.id
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
if let cached = exampleCache.examples(for: verbId)?.first {
exampleByVerbId[verbId] = cached
return
}
guard VerbExampleGenerator.isAvailable else { return }
generatingVerbIds.insert(verbId)
let infinitive = verb.infinitive
let english = verb.english
let formsByTense = ReferenceStore(context: localContext)
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
Task {
do {
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
tenseIds: VocabExampleTenseIds.canonical,
formsByTense: formsByTense
)
exampleCache.setExamples(examples, for: verbId)
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
if let pick, currentVerb?.id == verbId {
exampleByVerbId[verbId] = pick
}
} catch {}
generatingVerbIds.remove(verbId)
}
}
}
@@ -130,6 +130,7 @@ struct VocabReviewView: View {
private func rate(quality: ReviewQuality) {
guard let card = dueCards[safe: currentIndex] else { return }
ReviewStore.recordActivity(context: cloudContext)
let store = CourseReviewStore(context: cloudContext)
let result = SRSEngine.review(
quality: quality,
@@ -0,0 +1,99 @@
import SwiftUI
import SwiftData
import SharedModels
/// Lists downloaded YouTube videos with per-item deletion and total-size
/// summary (Issue #21, phase 4). Files live in the local (non-synced)
/// SwiftData container and the app's documents directory.
struct DownloadedVideosView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \DownloadedVideo.downloadedAt, order: .reverse)
private var downloads: [DownloadedVideo]
@State private var downloadService = VideoDownloadService.shared
@State private var confirmDeleteAll = false
private var totalBytes: Int {
downloads.reduce(0) { $0 + $1.byteCount }
}
var body: some View {
List {
if downloads.isEmpty {
Section {
ContentUnavailableView(
"No downloads",
systemImage: "arrow.down.to.line",
description: Text("Tap Download on any guide or grammar video to save it for offline viewing.")
)
}
} else {
Section {
LabeledContent("Total size", value: sizeString(totalBytes))
if totalBytes > 500_000_000 {
Label("Downloads exceed 500 MB", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
Section("Videos") {
ForEach(downloads) { download in
VStack(alignment: .leading, spacing: 4) {
Text(download.title)
.font(.subheadline.weight(.medium))
.lineLimit(2)
HStack {
Text(sizeString(download.byteCount))
Text("·")
Text(download.downloadedAt.formatted(date: .abbreviated, time: .omitted))
}
.font(.caption)
.foregroundStyle(.secondary)
}
.swipeActions {
Button(role: .destructive) {
downloadService.delete(videoId: download.videoId, modelContext: modelContext)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
Section {
Button(role: .destructive) {
confirmDeleteAll = true
} label: {
Label("Delete all downloads", systemImage: "trash")
}
}
}
}
.navigationTitle("Downloaded Videos")
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
"Delete all \(downloads.count) downloaded videos?",
isPresented: $confirmDeleteAll,
titleVisibility: .visible
) {
Button("Delete All", role: .destructive) {
for download in downloads {
downloadService.delete(videoId: download.videoId, modelContext: modelContext)
}
}
Button("Cancel", role: .cancel) {}
}
}
private func sizeString(_ bytes: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
}
#Preview {
NavigationStack {
DownloadedVideosView()
}
.modelContainer(for: DownloadedVideo.self, inMemory: true)
}
@@ -3,15 +3,17 @@ import SwiftUI
struct FeatureReferenceView: View {
var body: some View {
List {
Section("Verb Conjugation Practice") {
// MARK: Conjugation
Section("Practice — Conjugation") {
featureRow(
icon: "rectangle.stack", color: .blue,
title: "Flashcard / Typing / MC / Handwriting / Sentence Builder",
details: [
"Pulls from verb conjugation database (1,750 verbs)",
"Pulls from the verb conjugation database (1,750 verbs)",
"Filtered by your Level setting",
"Filtered by your Enabled Tenses",
"Respects Include Vosotros setting",
"Respects the Include Vosotros setting",
"Due cards (SRS) shown first, then random",
]
)
@@ -21,13 +23,11 @@ struct FeatureReferenceView: View {
title: "Full Table",
details: [
"Shows all 6 person forms for one verb + tense",
"Random verb from your Level",
"Drawn from any regular verb — Level filter is ignored here on purpose, since regular conjugation patterns transfer across vocabulary",
"Random tense from your Enabled Tenses",
]
)
}
Section("Quick Actions") {
featureRow(
icon: "star.fill", color: .orange,
title: "Common Tenses",
@@ -57,20 +57,86 @@ struct FeatureReferenceView: View {
"Filtered by your Level and Enabled Tenses",
]
)
}
// MARK: Vocabulary
Section("Practice — Vocabulary") {
featureRow(
icon: "rectangle.on.rectangle.angled", color: .purple,
title: "Vocab Flashcards",
details: [
"English meaning → recall the Spanish verb",
"Pool = verbs at your enabled Levels (the same Level set the Verbs tab filters by)",
"Session size set by Settings → Cards per session",
"Overdue verbs pulled first, then new verbs by frequency",
"Quiz mode: tap to reveal, rate Again/Hard/Good/Easy. Again/Hard requeue the card a few cards later; a second Good or an Easy graduates it. Graduation updates the long-term SRS schedule.",
"Learn mode (toolbar toggle): both sides shown at once, Next/Previous to browse, loops — no rating, no pressure",
"Example sentence generated on-device; speaker button reads the verb aloud",
]
)
featureRow(
icon: "checklist", color: .purple,
title: "Vocab Multiple Choice",
details: [
"English meaning → pick the Spanish verb from 4 options",
"Distractors prefer the same part of speech",
"Same level-filtered pool and SRS session queue as Vocab Flashcards",
]
)
featureRow(
icon: "rectangle.stack.fill", color: .teal,
title: "Vocab Review",
details: [
"Reviews vocabulary cards that are due (SRS scheduled)",
"Reviews course/textbook vocabulary cards that are due (SRS scheduled)",
"Cards become due after you study them in Course quizzes",
"Rate Again/Hard/Good/Easy to schedule next review",
"Uses all course vocabulary, not filtered by level",
"Rate Again/Hard/Good/Easy to schedule the next review",
"Distinct from Vocab Flashcards — this is course vocab, not the verb table",
]
)
}
Section("Practice Activities") {
// MARK: Reading
Section("Practice — Reading") {
featureRow(
icon: "book.fill", color: .teal,
title: "Stories",
details: [
"AI-generated one-paragraph Spanish stories",
"Matched to your Level and Enabled Tenses",
"Every word is tappable for a definition",
"English translation hidden by default (toggle to reveal)",
"3-question comprehension quiz at the end",
"Requires an Apple Intelligence-capable device",
]
)
featureRow(
icon: "books.vertical.fill", color: .indigo,
title: "Books",
details: [
"Full-length bilingual books bundled with the app",
"Chapter list; read a chapter paragraph by paragraph",
"Tap any word for a definition (offline dictionary, on-device AI fallback)",
"Toggle between Spanish and pre-translated English",
"Read-aloud: a voice reads the chapter with the current word highlighted; tap a word to pause and look it up",
"Voice and speed picker in the read-aloud controls",
]
)
featureRow(
icon: "music.note.list", color: .pink,
title: "Lyrics",
details: [
"Search and save Spanish song lyrics",
"Side-by-side Spanish and English",
"Long-press a word for a definition",
]
)
featureRow(
icon: "bubble.left.and.bubble.right.fill", color: .green,
title: "Conversation",
@@ -79,8 +145,7 @@ struct FeatureReferenceView: View {
"10 scenario types (restaurant, directions, etc.)",
"AI adapts vocabulary to your Level setting",
"Corrections provided inline when you make mistakes",
"Conversations saved to iCloud for revisiting",
"Requires Apple Intelligence-capable device",
"Requires an Apple Intelligence-capable device",
]
)
@@ -91,8 +156,6 @@ struct FeatureReferenceView: View {
"Listen & Type: hear a sentence, type what you heard",
"Pronunciation: read a sentence aloud, get scored on accuracy",
"Sentences pulled from course vocabulary examples",
"Uses all course vocab (not filtered by level)",
"Pronunciation requires microphone permission",
]
)
@@ -101,50 +164,23 @@ struct FeatureReferenceView: View {
title: "Cloze Practice",
details: [
"Fill in the missing word in a Spanish sentence",
"Sentences from course vocabulary examples",
"4 multiple-choice options (1 correct + 3 distractors)",
"Distractors are other vocabulary words from same pool",
"Uses all course vocab (not filtered by level)",
]
)
featureRow(
icon: "music.note.list", color: .pink,
title: "Lyrics",
details: [
"Search and save Spanish song lyrics",
"Side-by-side Spanish and English translations",
"User-curated library, not filtered by level",
"Saved to iCloud for sync across devices",
]
)
featureRow(
icon: "book.fill", color: .teal,
title: "Stories",
details: [
"AI-generated one-paragraph Spanish stories",
"Matched to your Level and Enabled Tenses",
"Every word is tappable for definition",
"Known words use offline dictionary (175K+ verb forms)",
"Unknown words looked up via on-device AI",
"English translation hidden by default (toggle to reveal)",
"3-question comprehension quiz at the end",
"Saved to iCloud for revisiting",
"Requires Apple Intelligence-capable device",
"Sentences from course vocabulary examples",
]
)
}
// MARK: Guide
Section("Guide") {
featureRow(
icon: "book", color: .brown,
title: "Tense Guides",
details: [
"Detailed explanation of each of the 20 verb tenses",
"Conjugation ending tables for -ar, -er, -ir verbs",
"Usage patterns with example sentences",
"Essential tenses marked with orange badge",
"In-depth guide to each of the 20 verb tenses",
"Conjugation ending tables, common irregulars, mnemonics",
"Usage patterns, pitfalls, and contrast with neighbouring tenses",
"Essential tenses marked with an orange badge",
]
)
@@ -152,14 +188,24 @@ struct FeatureReferenceView: View {
icon: "doc.text", color: .brown,
title: "Grammar Notes",
details: [
"23 grammar topics (ser vs estar, por vs para, etc.)",
"Interactive exercises available for 5 topics",
"Tap 'Practice This' on notes that have exercises",
"Content grouped by category with card-based layout",
"36 grammar topics (ser vs estar, por vs para, WEIRDO, etc.)",
"Each with a mnemonic, contrast examples, and common pitfalls",
"Interactive exercises available on selected topics",
]
)
featureRow(
icon: "arrow.triangle.branch", color: .indigo,
title: "Cross-links",
details: [
"Tense guides show \"Related grammar\" chips that jump to the matching grammar note",
"Grammar notes show \"Used in tenses\" chips that jump back",
]
)
}
// MARK: Course
Section("Course") {
featureRow(
icon: "list.clipboard", color: .orange,
@@ -167,11 +213,21 @@ struct FeatureReferenceView: View {
details: [
"Vocabulary from specific course weeks",
"Multiple quiz types: MC, typing, handwriting, cloze",
"Focus Area mode for missed words",
"Not filtered by Level (uses course structure)",
]
)
featureRow(
icon: "star.circle.fill", color: .yellow,
title: "Extra Study",
details: [
"Star a card during a course flashcard session to mark it for extra study",
"Each week shows an \"Extra Study\" row when it has starred cards",
"Launches a session of just the starred cards for that week",
"Marks are iCloud-synced across devices",
]
)
featureRow(
icon: "checkmark.seal", color: .orange,
title: "Checkpoint Exams",
@@ -183,23 +239,27 @@ struct FeatureReferenceView: View {
)
}
// MARK: Dashboard
Section("Dashboard") {
featureRow(
icon: "clock.fill", color: .mint,
title: "Study Time",
details: [
"Tracks time the app is in the foreground",
"Starts when app becomes active, stops on background",
"Shows today's time and all-time total",
"Shows today's time and an all-time total",
"7-day bar chart of daily study time",
]
)
}
// MARK: Settings
Section("Settings That Affect Practice") {
settingRow(name: "Level", affects: "Verb practice, Full Table, Quick Actions, Stories, Conversation")
settingRow(name: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories")
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions")
settingRow(name: "Level", affects: "Conjugation practice, Vocab Flashcards & Multiple Choice, Stories, Conversation. Shared with the Verbs tab filter. Full Table ignores level.")
settingRow(name: "Enabled Tenses", affects: "Conjugation practice, Full Table, Irregularity Drills, Stories")
settingRow(name: "Include Vosotros", affects: "Conjugation practice, Full Table, Common Tenses")
settingRow(name: "Cards per session", affects: "How many verbs a Vocab Flashcards / Multiple Choice session draws")
settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")
}
}
+102 -13
View File
@@ -9,9 +9,17 @@ struct SettingsView: View {
@State private var dailyGoal: Double = 50
@State private var showVosotros: Bool = true
@State private var autoFillStem: Bool = false
@State private var selectedLevel: VerbLevel = .basic
/// Cards per study session, per word type. 999 = "All" (no cap).
@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 levels = VerbLevel.allCases
private let irregularCategories: [IrregularSpan.SpanCategory] = [
.spelling, .stemChange, .uniqueIrregular
]
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View {
@@ -40,19 +48,55 @@ struct SettingsView: View {
}
}
Section("Level") {
Picker("Current Level", selection: $selectedLevel) {
ForEach(levels, id: \.self) { level in
Text(level.displayName).tag(level)
}
}
.onChange(of: selectedLevel) { _, newValue in
progress?.selectedVerbLevel = newValue
saveProgress()
}
Section {
sessionSizePicker("Verbs per session", selection: $vocabSessionCardLimit)
sessionSizePicker("Nouns per session", selection: $nounSessionCardLimit)
sessionSizePicker("Adjectives per session", selection: $adjectiveSessionCardLimit)
} header: {
Text("Cards Per Session")
} footer: {
Text("How many cards each flashcard or multiple-choice session draws, per word type. Overdue cards are pulled first, then new ones.")
}
Section("Tenses") {
Section {
ForEach(levels, id: \.self) { level in
Toggle(level.displayName, isOn: Binding(
get: {
progress?.selectedVerbLevels.contains(level) ?? false
},
set: { enabled in
guard let progress else { return }
progress.setLevelEnabled(level, enabled: enabled)
saveProgress()
}
))
}
} header: {
Text("Verb Levels")
} footer: {
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
}
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()
}
))
}
} 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 {
ForEach(TenseInfo.all) { tense in
Toggle(tense.english, isOn: Binding(
get: {
@@ -65,6 +109,41 @@ struct SettingsView: View {
}
))
}
} header: {
Text("Tenses")
}
Section {
ForEach(irregularCategories, id: \.self) { category in
Toggle(category.rawValue, isOn: Binding(
get: {
progress?.enabledIrregularCategories.contains(category) ?? false
},
set: { enabled in
guard let progress else { return }
progress.setIrregularCategoryEnabled(category, enabled: enabled)
saveProgress()
}
))
}
} header: {
Text("Irregular Types")
} footer: {
Text("Leave all off to include regular and irregular verbs. Enable any to restrict practice to those irregularity types.")
}
Section {
Toggle("Reflexive verbs only", isOn: Binding(
get: { progress?.showReflexiveVerbsOnly ?? false },
set: { enabled in
progress?.showReflexiveVerbsOnly = enabled
saveProgress()
}
))
} header: {
Text("Reflexive")
} footer: {
Text("When on, practice pulls only from the curated list of common reflexive verbs.")
}
Section("Stats") {
@@ -79,6 +158,9 @@ struct SettingsView: View {
NavigationLink("How Features Work") {
FeatureReferenceView()
}
NavigationLink("Downloaded Videos") {
DownloadedVideosView()
}
}
Section("About") {
@@ -90,13 +172,20 @@ 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 loadProgress() {
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress = resolved
dailyGoal = Double(resolved.dailyGoal)
showVosotros = resolved.showVosotros
autoFillStem = resolved.autoFillStem
selectedLevel = resolved.selectedVerbLevel
}
private func saveProgress() {
@@ -4,14 +4,40 @@ import SwiftData
struct VerbDetailView: View {
@Environment(\.modelContext) private var modelContext
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var speechService = SpeechService()
let verb: Verb
@State private var selectedTense: TenseInfo = TenseInfo.all[0]
@State private var examples: [VerbExample] = []
@State private var examplesState: ExamplesState = .idle
private enum ExamplesState: Equatable {
case idle
case loading
case loaded
case unavailable
case failed(String)
}
private static let exampleTenseIds: [String] = [
TenseID.ind_presente.rawValue,
TenseID.ind_preterito.rawValue,
TenseID.ind_imperfecto.rawValue,
TenseID.ind_futuro.rawValue,
TenseID.subj_presente.rawValue,
TenseID.imp_afirmativo.rawValue,
]
private var formsForTense: [VerbForm] {
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
}
private var reflexiveEntries: [ReflexiveVerb] {
reflexiveStore.entries(for: verb.infinitive)
}
var body: some View {
List {
Section {
@@ -25,6 +51,10 @@ struct VerbDetailView: View {
Text("Info")
}
if !reflexiveEntries.isEmpty {
reflexiveSection
}
Section {
Picker("Tense", selection: $selectedTense) {
ForEach(TenseInfo.all) { tense in
@@ -66,6 +96,8 @@ struct VerbDetailView: View {
} header: {
Text("Conjugation")
}
examplesSection
}
.navigationTitle(verb.infinitive)
.toolbar {
@@ -78,6 +110,129 @@ struct VerbDetailView: View {
.tint(.secondary)
}
}
.task(id: verb.id) {
await loadExamples()
}
}
// MARK: - Reflexive
private var reflexiveSection: some View {
Section {
ForEach(Array(reflexiveEntries.enumerated()), id: \.offset) { _, entry in
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(entry.infinitive)
.font(.body.weight(.semibold))
.italic()
if let hint = entry.usageHint, !hint.isEmpty {
Text(hint)
.font(.caption.weight(.medium))
.foregroundStyle(.tint)
}
}
Text(entry.english)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
} header: {
Text("Reflexive")
} footer: {
if reflexiveEntries.contains(where: { $0.usageHint != nil }) {
Text("Highlighted words are prepositions or phrases this verb commonly pairs with.")
.font(.caption2)
}
}
}
// MARK: - Examples
@ViewBuilder
private var examplesSection: some View {
Section {
switch examplesState {
case .idle, .loading:
HStack(spacing: 10) {
ProgressView()
Text("Generating examples…")
.font(.caption)
.foregroundStyle(.secondary)
}
case .unavailable:
Label("Examples require Apple Intelligence on this device.", systemImage: "sparkles")
.font(.caption)
.foregroundStyle(.secondary)
case .failed(let message):
Label(message, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.secondary)
case .loaded:
if examples.isEmpty {
Label("No examples available.", systemImage: "text.quote")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(Array(examples.enumerated()), id: \.offset) { _, example in
VStack(alignment: .leading, spacing: 4) {
if let info = TenseInfo.find(example.tenseId) {
Text(info.english)
.font(.caption2.weight(.semibold))
.foregroundStyle(.tint)
}
Text(example.spanish)
.font(.body)
.italic()
Text(example.english)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
}
} header: {
Text("Examples")
}
}
private func loadExamples() async {
// Reset state when navigating between verbs via NavigationSplitView.
examples = []
examplesState = .idle
if let cached = exampleCache.examples(for: verb.id), !cached.isEmpty {
examples = cached
examplesState = .loaded
return
}
guard VerbExampleGenerator.isAvailable else {
examplesState = .unavailable
return
}
examplesState = .loading
do {
let formsByTense = ReferenceStore(context: modelContext)
.conjugatedForms(verbId: verb.id, tenseIds: Self.exampleTenseIds)
let generated = try await VerbExampleGenerator.generate(
verbInfinitive: verb.infinitive,
verbEnglish: verb.english,
tenseIds: Self.exampleTenseIds,
formsByTense: formsByTense
)
guard !generated.isEmpty else {
examplesState = .failed("Could not generate examples.")
return
}
exampleCache.setExamples(generated, for: verb.id)
examples = generated
examplesState = .loaded
} catch {
examplesState = .failed("Could not generate examples.")
}
}
}
@@ -86,4 +241,6 @@ struct VerbDetailView: View {
VerbDetailView(verb: Verb(id: 1, infinitive: "hablar", english: "to speak", rank: 1, ending: "ar", reflexive: 0, level: "basic"))
}
.modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
.environment(VerbExampleCache())
.environment(ReflexiveVerbStore())
}
+241 -20
View File
@@ -2,17 +2,67 @@ import SwiftUI
import SwiftData
import SharedModels
enum IrregularityCategory: String, CaseIterable, Identifiable {
case anyIrregular = "Any Irregular"
case spelling = "Spelling Change"
case stemChange = "Stem Change"
case uniqueIrregular = "Unique Irregular"
var id: String { rawValue }
var systemImage: String {
switch self {
case .anyIrregular: "asterisk"
case .spelling: "character.cursor.ibeam"
case .stemChange: "arrow.triangle.2.circlepath"
case .uniqueIrregular: "star"
}
}
}
struct VerbListView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var verbs: [Verb] = []
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
@State private var searchText = ""
@State private var selectedLevel: String?
@State private var progress: UserProgress?
@State private var selectedIrregularity: IrregularityCategory?
@State private var reflexiveOnly: Bool = false
@State private var selectedVerb: Verb?
private var cloudContext: ModelContext { cloudModelContextProvider() }
/// Levels currently enabled in `UserProgress` the same set that drives
/// what Practice picks from. The Verbs tab reads and writes the same
/// state so changes here propagate to Practice and vice versa.
private var selectedLevels: Set<VerbLevel> {
progress?.selectedVerbLevels ?? []
}
/// True when the user has every available level enabled (or none, which
/// we treat as "no filter applied" on the Verbs list specifically).
private var allLevelsActive: Bool {
selectedLevels.isEmpty || selectedLevels.count == VerbLevel.allCases.count
}
private var filteredVerbs: [Verb] {
var result = verbs
if let level = selectedLevel {
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) }
if !allLevelsActive {
let activeRaws = Set(selectedLevels.map(\.rawValue))
result = result.filter { verb in
activeRaws.contains { VerbLevelGroup.matches(verb.level, selectedLevel: $0) }
}
}
if let category = selectedIrregularity {
result = result.filter { verb in
guard let cats = irregularityByVerbId[verb.id] else { return false }
return category == .anyIrregular ? !cats.isEmpty : cats.contains(category)
}
}
if reflexiveOnly {
result = result.filter { reflexiveStore.isReflexive(baseInfinitive: $0.infinitive) }
}
if !searchText.isEmpty {
let query = searchText.lowercased()
@@ -24,31 +74,75 @@ struct VerbListView: View {
return result
}
private let levels = ["basic", "elementary", "intermediate", "advanced", "expert"]
private let levels: [VerbLevel] = VerbLevel.allCases
var body: some View {
NavigationSplitView {
List(filteredVerbs, selection: $selectedVerb) { verb in
NavigationLink(value: verb) {
VerbRowView(verb: verb)
VerbRowView(verb: verb, irregularities: irregularityByVerbId[verb.id] ?? [])
}
}
.navigationTitle("Verbs")
.searchable(text: $searchText, prompt: "Search verbs...")
.safeAreaInset(edge: .top, spacing: 0) {
if hasActiveFilter {
activeFilterBar
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("All Levels") { selectedLevel = nil }
ForEach(levels, id: \.self) { level in
Button(level.capitalized) { selectedLevel = level }
Section("Level") {
Button {
setAllLevels(enabled: true)
} label: {
Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "circle")
}
ForEach(levels, id: \.self) { level in
Button {
toggleLevel(level)
} label: {
Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "circle")
}
}
}
Section("Irregularity") {
Button {
selectedIrregularity = nil
} label: {
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "circle")
}
ForEach(IrregularityCategory.allCases) { category in
Button {
selectedIrregularity = category
} label: {
Label(category.rawValue, systemImage: selectedIrregularity == category ? "checkmark" : category.systemImage)
}
}
}
Section("Reflexive") {
Button {
reflexiveOnly.toggle()
} label: {
Label("Reflexive verbs only", systemImage: reflexiveOnly ? "checkmark" : "arrow.triangle.2.circlepath")
}
}
} label: {
Label("Filter", systemImage: "line.3.horizontal.decrease.circle")
Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
}
}
}
.task { loadVerbs() }
.onAppear { loadVerbs() }
.task {
loadVerbs()
loadProgress()
}
.onAppear {
loadVerbs()
loadProgress()
}
} detail: {
if let verb = selectedVerb {
VerbDetailView(verb: verb)
@@ -58,6 +152,56 @@ struct VerbListView: View {
}
}
private var hasActiveFilter: Bool {
!allLevelsActive || selectedIrregularity != nil || reflexiveOnly
}
@ViewBuilder
private var activeFilterBar: some View {
HStack(spacing: 8) {
if !allLevelsActive {
filterChip(text: levelChipLabel, systemImage: "graduationcap") {
setAllLevels(enabled: true)
}
}
if let cat = selectedIrregularity {
filterChip(text: cat.rawValue, systemImage: cat.systemImage) {
selectedIrregularity = nil
}
}
if reflexiveOnly {
filterChip(text: "Reflexive", systemImage: "arrow.triangle.2.circlepath") {
reflexiveOnly = false
}
}
Spacer()
Text("\(filteredVerbs.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
private func filterChip(text: String, systemImage: String, onClear: @escaping () -> Void) -> some View {
Button(action: onClear) {
HStack(spacing: 4) {
Image(systemName: systemImage)
.font(.caption2)
Text(text)
.font(.caption.weight(.medium))
Image(systemName: "xmark")
.font(.caption2)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.blue.opacity(0.15), in: Capsule())
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
}
private func loadVerbs() {
// Hit the shared local container directly, bypassing @Environment.
guard let container = SharedStore.localContainer else {
@@ -69,12 +213,64 @@ struct VerbListView: View {
}
let context = ModelContext(container)
verbs = ReferenceStore(context: context).fetchVerbs()
print("[VerbListView] loaded \(verbs.count) verbs (container: \(ObjectIdentifier(container)))")
irregularityByVerbId = buildIrregularityIndex(context: context)
print("[VerbListView] loaded \(verbs.count) verbs, \(irregularityByVerbId.count) flagged irregular (container: \(ObjectIdentifier(container)))")
}
// MARK: - Level filter (shared with Practice via UserProgress)
private var levelChipLabel: String {
let names = selectedLevels
.sorted { $0.rawValue < $1.rawValue }
.map(\.displayName)
if names.isEmpty { return "No levels" }
if names.count == 1 { return names[0] }
return "\(names.count) levels"
}
private func loadProgress() {
progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
}
private func toggleLevel(_ level: VerbLevel) {
guard let progress else { return }
let enabled = !progress.selectedVerbLevels.contains(level)
progress.setLevelEnabled(level, enabled: enabled)
try? cloudContext.save()
}
private func setAllLevels(enabled: Bool) {
guard let progress else { return }
if enabled {
progress.selectedVerbLevels = Set(VerbLevel.allCases)
} else {
// Practice treats an empty set as "no verbs", so guard against
// leaving the user with nothing keep at least `basic`.
progress.selectedVerbLevels = [.basic]
}
try? cloudContext.save()
}
private func buildIrregularityIndex(context: ModelContext) -> [Int: Set<IrregularityCategory>] {
let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? []
var index: [Int: Set<IrregularityCategory>] = [:]
for span in spans {
let category: IrregularityCategory
switch span.spanType {
case 100..<200: category = .spelling
case 200..<300: category = .stemChange
case 300..<400: category = .uniqueIrregular
default: continue
}
index[span.verbId, default: []].insert(category)
}
return index
}
}
struct VerbRowView: View {
let verb: Verb
var irregularities: Set<IrregularityCategory> = []
var body: some View {
HStack {
@@ -88,14 +284,39 @@ struct VerbRowView: View {
Spacer()
Text(verb.level.prefix(3).uppercased())
.font(.caption2)
.fontWeight(.semibold)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(levelColor(verb.level).opacity(0.15))
.foregroundStyle(levelColor(verb.level))
.clipShape(Capsule())
HStack(spacing: 4) {
ForEach(orderedIrregularities, id: \.self) { cat in
Image(systemName: cat.systemImage)
.font(.caption2.weight(.semibold))
.foregroundStyle(irregularityColor(cat))
.help(cat.rawValue)
.accessibilityLabel(cat.rawValue)
}
Text(verb.level.prefix(3).uppercased())
.font(.caption2)
.fontWeight(.semibold)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(levelColor(verb.level).opacity(0.15))
.foregroundStyle(levelColor(verb.level))
.clipShape(Capsule())
}
}
}
private var orderedIrregularities: [IrregularityCategory] {
// Order: unique > stem > spelling (most notable first)
let order: [IrregularityCategory] = [.uniqueIrregular, .stemChange, .spelling]
return order.filter { irregularities.contains($0) }
}
private func irregularityColor(_ category: IrregularityCategory) -> Color {
switch category {
case .uniqueIrregular: return .purple
case .stemChange: return .orange
case .spelling: return .teal
case .anyIrregular: return .gray
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+104
View File
@@ -0,0 +1,104 @@
[
{"infinitive": "aburrirse", "baseInfinitive": "aburrir", "english": "to get bored"},
{"infinitive": "acercarse", "baseInfinitive": "acercar", "english": "to get close to", "usageHint": "a"},
{"infinitive": "acordarse", "baseInfinitive": "acordar", "english": "to remember", "usageHint": "de"},
{"infinitive": "acostarse", "baseInfinitive": "acostar", "english": "to lay down / to go to bed"},
{"infinitive": "acostumbrarse", "baseInfinitive": "acostumbrar", "english": "to get used to", "usageHint": "a"},
{"infinitive": "afeitarse", "baseInfinitive": "afeitar", "english": "to shave"},
{"infinitive": "alegrarse", "baseInfinitive": "alegrar", "english": "to be glad / happy / pleased"},
{"infinitive": "alejarse", "baseInfinitive": "alejar", "english": "to get away from", "usageHint": "de"},
{"infinitive": "animarse", "baseInfinitive": "animar", "english": "to cheer up / to dare to do something", "usageHint": "a"},
{"infinitive": "apurarse", "baseInfinitive": "apurar", "english": "to hurry"},
{"infinitive": "aprovecharse", "baseInfinitive": "aprovechar", "english": "to take advantage of", "usageHint": "de"},
{"infinitive": "asustarse", "baseInfinitive": "asustar", "english": "to get or become afraid"},
{"infinitive": "atreverse", "baseInfinitive": "atrever", "english": "to dare to", "usageHint": "a"},
{"infinitive": "bañarse", "baseInfinitive": "bañar", "english": "to take a bath / shower"},
{"infinitive": "burlarse", "baseInfinitive": "burlar", "english": "to make fun of", "usageHint": "de"},
{"infinitive": "caerse", "baseInfinitive": "caer", "english": "to fall down"},
{"infinitive": "calmarse", "baseInfinitive": "calmar", "english": "to calm down"},
{"infinitive": "cansarse", "baseInfinitive": "cansar", "english": "to get tired (of)", "usageHint": "(de)"},
{"infinitive": "casarse", "baseInfinitive": "casar", "english": "to marry", "usageHint": "con"},
{"infinitive": "cepillarse", "baseInfinitive": "cepillar", "english": "to brush (hair, teeth)"},
{"infinitive": "deprimirse", "baseInfinitive": "deprimir", "english": "to become depressed"},
{"infinitive": "conformarse", "baseInfinitive": "conformar", "english": "to resign oneself to", "usageHint": "con"},
{"infinitive": "volverse", "baseInfinitive": "volver", "english": "to become / to turn into / to return"},
{"infinitive": "darse", "baseInfinitive": "dar", "english": "to realize", "usageHint": "cuenta de"},
{"infinitive": "dedicarse", "baseInfinitive": "dedicar", "english": "to dedicate oneself to / to do for a living", "usageHint": "a"},
{"infinitive": "despedirse", "baseInfinitive": "despedir", "english": "to say goodbye", "usageHint": "(de)"},
{"infinitive": "despertarse", "baseInfinitive": "despertar", "english": "to wake up"},
{"infinitive": "desvestirse", "baseInfinitive": "desvestir", "english": "to undress"},
{"infinitive": "dirigirse", "baseInfinitive": "dirigir", "english": "to go to / make one's way toward / to address", "usageHint": "a"},
{"infinitive": "hacerse", "baseInfinitive": "hacer", "english": "to become / to pretend"},
{"infinitive": "divertirse", "baseInfinitive": "divertir", "english": "to have fun"},
{"infinitive": "dormirse", "baseInfinitive": "dormir", "english": "to fall asleep / to oversleep"},
{"infinitive": "ducharse", "baseInfinitive": "duchar", "english": "to shower"},
{"infinitive": "echarse", "baseInfinitive": "echar", "english": "to begin (usually suddenly) to do something / to break into", "usageHint": "a"},
{"infinitive": "enamorarse", "baseInfinitive": "enamorar", "english": "to fall in love with", "usageHint": "de"},
{"infinitive": "encargarse", "baseInfinitive": "encargar", "english": "to take charge of or be responsible for", "usageHint": "de"},
{"infinitive": "encogerse", "baseInfinitive": "encoger", "english": "to shrug (shoulders)", "usageHint": "(de hombros)"},
{"infinitive": "encontrarse", "baseInfinitive": "encontrar", "english": "to meet with / to run into someone", "usageHint": "(con)"},
{"infinitive": "enfermarse", "baseInfinitive": "enfermar", "english": "to get sick"},
{"infinitive": "enojarse", "baseInfinitive": "enojar", "english": "to get or become angry"},
{"infinitive": "enterarse", "baseInfinitive": "enterar", "english": "to find out, to realize", "usageHint": "de"},
{"infinitive": "exponerse", "baseInfinitive": "exponer", "english": "to expose oneself to or run the risk of", "usageHint": "a"},
{"infinitive": "fijarse", "baseInfinitive": "fijar", "english": "to pay attention to / to take a look"},
{"infinitive": "jugarse", "baseInfinitive": "jugar", "english": "to risk"},
{"infinitive": "lastimarse", "baseInfinitive": "lastimar", "english": "to get hurt or hurt oneself"},
{"infinitive": "lavarse", "baseInfinitive": "lavar", "english": "to wash (a body part)"},
{"infinitive": "levantarse", "baseInfinitive": "levantar", "english": "to get up"},
{"infinitive": "maquillarse", "baseInfinitive": "maquillar", "english": "to put makeup on"},
{"infinitive": "meterse", "baseInfinitive": "meter", "english": "to get into / to pick on / to pick a fight with", "usageHint": "en / con"},
{"infinitive": "motivarse", "baseInfinitive": "motivar", "english": "to become or get motivated to"},
{"infinitive": "moverse", "baseInfinitive": "mover", "english": "to move oneself"},
{"infinitive": "mudarse", "baseInfinitive": "mudar", "english": "to move (change residence)"},
{"infinitive": "negarse", "baseInfinitive": "negar", "english": "to refuse to", "usageHint": "a"},
{"infinitive": "obsesionarse", "baseInfinitive": "obsesionar", "english": "to be or get obsessed with", "usageHint": "con"},
{"infinitive": "ocuparse", "baseInfinitive": "ocupar", "english": "to look after", "usageHint": "de"},
{"infinitive": "olvidarse", "baseInfinitive": "olvidar", "english": "to forget", "usageHint": "de"},
{"infinitive": "parecerse", "baseInfinitive": "parecer", "english": "to look like someone or something", "usageHint": "a"},
{"infinitive": "peinarse", "baseInfinitive": "peinar", "english": "to comb your hair"},
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to put on (clothing) / to get or become"},
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to come to an agreement with someone", "usageHint": "de acuerdo"},
{"infinitive": "preocuparse", "baseInfinitive": "preocupar", "english": "to worry about", "usageHint": "por"},
{"infinitive": "prepararse", "baseInfinitive": "preparar", "english": "to prepare to"},
{"infinitive": "probarse", "baseInfinitive": "probar", "english": "to try on"},
{"infinitive": "quebrarse", "baseInfinitive": "quebrar", "english": "to break (an arm, leg, etc.)"},
{"infinitive": "quejarse", "baseInfinitive": "quejar", "english": "to complain about", "usageHint": "de"},
{"infinitive": "quedarse", "baseInfinitive": "quedar", "english": "to remain / to stay"},
{"infinitive": "quemarse", "baseInfinitive": "quemar", "english": "to burn oneself / one's body"},
{"infinitive": "quitarse", "baseInfinitive": "quitar", "english": "to take off (clothing, etc.)"},
{"infinitive": "reírse", "baseInfinitive": "reír", "english": "to laugh about", "usageHint": "de"},
{"infinitive": "resignarse", "baseInfinitive": "resignar", "english": "to resign oneself to", "usageHint": "a"},
{"infinitive": "romperse", "baseInfinitive": "romper", "english": "to break (an arm, leg, etc.)"},
{"infinitive": "secarse", "baseInfinitive": "secar", "english": "to dry (a body part)"},
{"infinitive": "sentarse", "baseInfinitive": "sentar", "english": "to sit down"},
{"infinitive": "sentirse", "baseInfinitive": "sentir", "english": "to feel"},
{"infinitive": "servirse", "baseInfinitive": "servir", "english": "to help oneself to (food)"},
{"infinitive": "suicidarse", "baseInfinitive": "suicidar", "english": "to commit suicide"},
{"infinitive": "tratarse", "baseInfinitive": "tratar", "english": "to be about", "usageHint": "de"},
{"infinitive": "vestirse", "baseInfinitive": "vestir", "english": "to get dressed"},
{"infinitive": "marearse", "baseInfinitive": "marear", "english": "to get sick, to get dizzy"},
{"infinitive": "irse", "baseInfinitive": "ir", "english": "to leave"},
{"infinitive": "imaginarse", "baseInfinitive": "imaginar", "english": "to imagine"},
{"infinitive": "preguntarse", "baseInfinitive": "preguntar", "english": "to wonder"},
{"infinitive": "llamarse", "baseInfinitive": "llamar", "english": "to be called"},
{"infinitive": "verse", "baseInfinitive": "ver", "english": "to look or appear"},
{"infinitive": "distraerse", "baseInfinitive": "distraer", "english": "to get distracted"},
{"infinitive": "concentrarse", "baseInfinitive": "concentrar", "english": "to focus"},
{"infinitive": "rendirse", "baseInfinitive": "rendir", "english": "to give up"},
{"infinitive": "relajarse", "baseInfinitive": "relajar", "english": "to relax"},
{"infinitive": "merecerse", "baseInfinitive": "merecer", "english": "to deserve"},
{"infinitive": "suponerse", "baseInfinitive": "suponer", "english": "to suppose"},
{"infinitive": "conectarse", "baseInfinitive": "conectar", "english": "to connect"},
{"infinitive": "destacarse", "baseInfinitive": "destacar", "english": "to stand out"},
{"infinitive": "recibirse", "baseInfinitive": "recibir", "english": "to graduate"},
{"infinitive": "graduarse", "baseInfinitive": "graduar", "english": "to graduate"},
{"infinitive": "perderse", "baseInfinitive": "perder", "english": "to get lost"},
{"infinitive": "cambiarse", "baseInfinitive": "cambiar", "english": "to change (clothing)", "usageHint": "(de ropa)"},
{"infinitive": "adaptarse", "baseInfinitive": "adaptar", "english": "to adapt, to adjust", "usageHint": "a"},
{"infinitive": "salirse", "baseInfinitive": "salir", "english": "to get away with", "usageHint": "con (la suya)"},
{"infinitive": "subirse", "baseInfinitive": "subir", "english": "to get on (the bus, etc.)", "usageHint": "a"},
{"infinitive": "tranquilizarse", "baseInfinitive": "tranquilizar", "english": "to relax"},
{"infinitive": "equivocarse", "baseInfinitive": "equivocar", "english": "to get something wrong / confused"},
{"infinitive": "confundirse", "baseInfinitive": "confundir", "english": "to get something wrong / confused"}
]
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+63
View File
@@ -0,0 +1,63 @@
{
"note": "Curated YouTube videos per guide/grammar item for Issue #21. Each entry: {videoId, title}. Missing entries surface a 'No video yet' label in the app. TheLanguageTutor videos preferred where a matching lesson exists.",
"tenseGuides": {
"ind_presente": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
"ind_preterito": {"videoId": "0PR7G81gKEc", "title": "Past Tense Verbs You Must Learn in Spanish — The Language Tutor Lesson 64"},
"ind_imperfecto": {"videoId": "AmnTX30VliE", "title": "Mastering the Imperfect Tense in Spanish — The Language Tutor Lesson 50"},
"ind_futuro": {"videoId": "U42loE1zhdw", "title": "Spanish Verbs in Future Tense — The Language Tutor Lesson 51"},
"ind_perfecto": {"videoId": "-uHwV5Lu310", "title": "Present Perfect Indicative Tense in Spanish — The Language Tutor Lesson 55"},
"ind_pluscuamperfecto": {"videoId": "T_M3h88BUUw", "title": "Understanding Past Perfect Indicative Tense in Spanish — The Language Tutor Lesson 57"},
"ind_futuro_perfecto": {"videoId": "6cgc5ENNbR4", "title": "Understanding the Future Perfect Indicative in Spanish — The Language Tutor Lesson 62"},
"ind_preterito_anterior": {"videoId": "OCCgeYLlqck", "title": "The Past Anterior Tense in Spanish — The Language Tutor Lesson 65"},
"cond_presente": {"videoId": "nRaMf1Y1TCM", "title": "Understanding the Conditional Tense in Spanish — The Language Tutor Lesson 52"},
"cond_perfecto": {"videoId": "nlF-8kg-xaM", "title": "Mastering the Conditional Perfect Tense in Spanish — The Language Tutor Lesson 63"},
"subj_presente": {"videoId": "CRvXpo45oHw", "title": "The Subjunctive in Spanish — The Language Tutor Lesson 58"},
"subj_imperfecto_1": {"videoId": "5VkCYZGvNlI", "title": "Imperfect Subjunctive in Spanish — The Language Tutor Lesson 59"},
"subj_imperfecto_2": {"videoId": "5VkCYZGvNlI", "title": "Imperfect Subjunctive in Spanish — The Language Tutor Lesson 59"},
"subj_perfecto": {"videoId": "Sm2DDq99Uzw", "title": "Unlock Fluent Spanish: Present Perfect Subjunctive Mastery! — The Language Tutor"},
"subj_pluscuamperfecto_1": {"videoId": "am0YiYkTQ_E", "title": "Understanding the Past Perfect Subjunctive in Spanish — The Language Tutor Lesson 61"},
"subj_pluscuamperfecto_2": {"videoId": "am0YiYkTQ_E", "title": "Understanding the Past Perfect Subjunctive in Spanish — The Language Tutor Lesson 61"},
"subj_futuro": {"videoId": "YPWJsmD3hN4", "title": "Spanish Answers, Episode 10: Future Subjunctive"},
"subj_futuro_perfecto": {"videoId": "9vmo2C-0iuQ", "title": "Free Spanish Lessons 151 - Spanish Subjunctive Tense: Future Perfect"},
"imp_afirmativo": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"},
"imp_negativo": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"}
},
"grammarNotes": {
"ser-vs-estar": {"videoId": "tFXGCUi2yls", "title": "Ser vs Estar — BaseLang Spanish Lesson #3"},
"por-vs-para": {"videoId": "02Am4LlPGjU", "title": "When to Use Por or Para in Spanish — The Language Tutor Lesson 47"},
"preterite-vs-imperfect": {"videoId": "LvhO2-azzig", "title": "Spanish Past Tense: When to Use the Preterite vs Imperfect — BaseLang"},
"subjunctive-triggers": {"videoId": "pMAMDgNNPB0", "title": "Spanish Subjunctive Simplified For Beginners — BaseLang"},
"reflexive-verbs": {"videoId": "_uH_tosBLyo", "title": "Reflexive Verbs in Spanish — The Language Tutor Lesson 37"},
"object-pronouns": {"videoId": "gEe4NW1FXx4", "title": "Intro To Spanish Direct And Indirect Object Pronouns — BaseLang"},
"gustar-like-verbs": {"videoId": "SAfXpyZlz-I", "title": "How to Use Gustar in Spanish — The Language Tutor Lesson 121"},
"comparatives-superlatives": {"videoId": "U74ClJsbfb0", "title": "Comparatives and Superlatives in Spanish — The Language Tutor Lesson 123"},
"conditional-if-clauses": {"videoId": "thvW8qVsqkE", "title": "Si Clauses: The Spanish Hypothetical Explained — BaseLang"},
"commands-imperative": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"},
"saber-vs-conocer": {"videoId": "j87i7MVCvIE", "title": "Saber vs. Conocer: Right (and WRONG) Times to Use These Spanish Verbs"},
"double-negatives": {"videoId": "Y887wmI0O_o", "title": "Understanding Negation Words in Spanish — The Language Tutor Lesson 67"},
"adjective-placement": {"videoId": "2B_TK_aun8E", "title": "Adjectives and Nouns Working Together in Spanish — The Language Tutor Lesson 66"},
"tener-expressions": {"videoId": "189Qg68VCmo", "title": "The Verb Tener — BaseLang Spanish Lesson #17"},
"personal-a": {"videoId": "B38CwLxgmOc", "title": "Learn Spanish — Preposition 'A' vs. Personal 'A'"},
"relative-pronouns": {"videoId": "fnD1VaLpsCA", "title": "How to Use Relative Pronouns in Spanish — The Language Tutor Lesson 73"},
"future-vs-ir-a": {"videoId": "2nxynOxr_h4", "title": "Future Tense in Spanish: 3 Ways To Speak About The Future — BaseLang"},
"accent-marks-stress": {"videoId": "mXro8ngx07A", "title": "Spanish Accent Marks: When and How to Use Them — The Language Tutor"},
"se-constructions": {"videoId": "ndxsrGD7b-8", "title": "Understanding 'SE' in Spanish: Reflexive, Passive, and Impersonal Constructions"},
"estar-gerund-progressive": {"videoId": "s6HeVBv-ctM", "title": "How to Use Gerunds '-ing' in Spanish — The Language Tutor Lesson 113"},
"spanish-suffixes": {"videoId": "o88gkstA0ds", "title": "Diminutives and Augmentatives in Spanish — The Language Tutor Lesson 78"},
"common-irregular-verbs": {"videoId": "l0rOmomHSxg", "title": "Irregular Verbs in Spanish — The Language Tutor Lesson 21"},
"types-of-irregular-verbs": {"videoId": "-XogD_S7pY4", "title": "Master IRREGULAR VERBS in the PRESENT TENSE | Complete Spanish Lesson"},
"present-indicative-conjugation": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
"articles-and-gender": {"videoId": "YeTIwDcKwZ4", "title": "Definite and Indefinite Articles in Spanish — The Language Tutor Lesson 11"},
"possessive-adjectives": {"videoId": "Y4Aw1jy0ohE", "title": "Possessive Adjectives in Spanish — BaseLang"},
"demonstrative-adjectives": {"videoId": "g4UzE8c2wik", "title": "How to Use This, These, That and Those in Spanish — The Language Tutor Lesson 56"},
"greetings-farewells": {"videoId": "AqfQQZVmTUw", "title": "Every Spanish Greeting You Need (Formal, Casual & Slang) — The Language Tutor"},
"poder-infinitive": {"videoId": "hCUbz5942EY", "title": "Spanish - The Verb 'Poder' Explained In 3 Minutes"},
"al-del-contractions": {"videoId": "nWPZZWIwWxg", "title": "Spanish Contractions AL and DEL — The Language Tutor Lesson 15"},
"prepositional-pronouns": {"videoId": "quzRXk0oKp8", "title": "Mastering Prepositional Pronouns in Spanish — The Language Tutor Lesson 71"},
"irregular-yo-verbs": {"videoId": "bM9NLgaeUvw", "title": "What are YO GO verbs in Spanish? — BaseLang"},
"stem-changing-verbs": {"videoId": "WB2ThQauaWo", "title": "Stem Changing Verbs in Spanish: Explained For Beginners — BaseLang"},
"stressed-possessives": {"videoId": "OL86D_omkSQ", "title": "Learning Possessive Pronouns in Spanish — The Language Tutor Lesson 68"},
"present-perfect-tense": {"videoId": "-uHwV5Lu310", "title": "Present Perfect Indicative Tense in Spanish — The Language Tutor Lesson 55"},
"future-perfect-tense": {"videoId": "6cgc5ENNbR4", "title": "Understanding the Future Perfect Indicative in Spanish — The Language Tutor Lesson 62"}
}
}
+79
View File
@@ -0,0 +1,79 @@
# Curated YouTube Videos
Every tense guide and grammar note in the app can be tied to a single curated YouTube video. This file is generated from `Conjuga/youtube_videos.json` by `Scripts/generate_videos_markdown.py` — regenerate when you add or change entries.
- Total tense-guide entries: **20** of 20
- Total grammar-note entries: **36** of 36
- Last verified: **2026-04-23** (run `python3 Scripts/generate_videos_markdown.py` to refresh)
Like counts are often blank because YouTube hides the public count on most videos for signed-out requests. Titles and durations are pulled live from YouTube; unavailable entries mean the video has been taken down, made private, or region-locked. A few entries marked "not available on this app" are a transient yt-dlp extraction limit — the video itself still plays fine when tapping Stream in the app.
## Tense guides
Tied to `TenseGuide.tenseId` in the Guide tab.
| Tense ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |
|---|---|---|---|---|---|---|---|
| `ind_presente` | Spanish Present Tense: Regular -AR -ER -IR verb conjugation | Learn Spanish with Spanish55 | 2019-10-18 | 7:14 | 51,288 | 233 | [watch](https://www.youtube.com/watch?v=8HWXJjxvOTE) |
| `ind_preterito` | Past Tense Verbs You Must Learn in Spanish \| The Language Tutor *Lesson 64* | The Language Tutor - Spanish | 2020-04-05 | 9:03 | 50,837 | 1,433 | [watch](https://www.youtube.com/watch?v=0PR7G81gKEc) |
| `ind_imperfecto` | Mastering the Imperfect Tense in Spanish \| The Language Tutor *Lesson 50* | The Language Tutor - Spanish | 2019-12-22 | 19:43 | 385,678 | 8,420 | [watch](https://www.youtube.com/watch?v=AmnTX30VliE) |
| `ind_futuro` | Spanish Verbs in Future Tense \| The Language Tutor *Lesson 51* | The Language Tutor - Spanish | 2019-12-30 | 13:58 | 389,408 | 9,542 | [watch](https://www.youtube.com/watch?v=U42loE1zhdw) |
| `ind_perfecto` | Present Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 55* | The Language Tutor - Spanish | 2020-01-29 | 18:09 | 273,176 | 6,676 | [watch](https://www.youtube.com/watch?v=-uHwV5Lu310) |
| `ind_pluscuamperfecto` | Understanding Past Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 57* | The Language Tutor - Spanish | 2020-02-09 | 7:46 | 132,330 | 3,400 | [watch](https://www.youtube.com/watch?v=T_M3h88BUUw) |
| `ind_futuro_perfecto` | Understanding the Future Perfect Indicative in Spanish \| The Language Tutor *Lesson 62* | The Language Tutor - Spanish | 2020-03-22 | 7:59 | 58,757 | 1,547 | [watch](https://www.youtube.com/watch?v=6cgc5ENNbR4) |
| `ind_preterito_anterior` | The Past Anterior Tense in Spanish \| The Language Tutor *Lesson 65* | The Language Tutor - Spanish | 2020-04-12 | 8:37 | 45,389 | 1,124 | [watch](https://www.youtube.com/watch?v=OCCgeYLlqck) |
| `cond_presente` | Understanding the Conditional Tense in Spanish \| The Language Tutor *Lesson 52* | The Language Tutor - Spanish | 2020-01-05 | 10:01 | 222,400 | 5,338 | [watch](https://www.youtube.com/watch?v=nRaMf1Y1TCM) |
| `cond_perfecto` | Mastering the Conditional Perfect Tense in Spanish \| The Language Tutor *Lesson 63* | The Language Tutor - Spanish | 2020-03-29 | 6:07 | 44,304 | 1,300 | [watch](https://www.youtube.com/watch?v=nlF-8kg-xaM) |
| `subj_presente` | The Subjunctive in Spanish \| The Language Tutor *Lesson 58* | The Language Tutor - Spanish | 2020-02-23 | 16:30 | 424,782 | 11,272 | [watch](https://www.youtube.com/watch?v=CRvXpo45oHw) |
| `subj_imperfecto_1` | Imperfect Subjunctive in Spanish \| The Language Tutor *Lesson 59* | The Language Tutor - Spanish | 2020-03-01 | 18:04 | 194,900 | 4,226 | [watch](https://www.youtube.com/watch?v=5VkCYZGvNlI) |
| `subj_imperfecto_2` | Imperfect Subjunctive in Spanish \| The Language Tutor *Lesson 59* | The Language Tutor - Spanish | 2020-03-01 | 18:04 | 194,900 | 4,226 | [watch](https://www.youtube.com/watch?v=5VkCYZGvNlI) |
| `subj_perfecto` | Unlock Fluent Spanish: Present Perfect Subjunctive Mastery! | The Language Tutor - Spanish | 2020-03-08 | 6:47 | 89,060 | 2,105 | [watch](https://www.youtube.com/watch?v=Sm2DDq99Uzw) |
| `subj_pluscuamperfecto_1` | Understanding the Past Perfect Subjunctive in Spanish \| The Language Tutor *Lesson 61* | The Language Tutor - Spanish | 2020-03-15 | 8:51 | 75,462 | 1,862 | [watch](https://www.youtube.com/watch?v=am0YiYkTQ_E) |
| `subj_pluscuamperfecto_2` | Understanding the Past Perfect Subjunctive in Spanish \| The Language Tutor *Lesson 61* | The Language Tutor - Spanish | 2020-03-15 | 8:51 | 75,462 | 1,862 | [watch](https://www.youtube.com/watch?v=am0YiYkTQ_E) |
| `subj_futuro` | Spanish Answers, Episode 10: Future Subjunctive | Spanish Answers | 2019-05-14 | 12:52 | 2,580 | 60 | [watch](https://www.youtube.com/watch?v=YPWJsmD3hN4) |
| `subj_futuro_perfecto` | Free Spanish Lessons 151-Spanish Subjunctive tense:Future Perfect-Video 1/2 | Aprender Idiomas y Cultura General con Rodrigo | 2014-05-18 | 10:00 | 2,025 | 20 | [watch](https://www.youtube.com/watch?v=9vmo2C-0iuQ) |
| `imp_afirmativo` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
| `imp_negativo` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
## Grammar notes
Tied to `GrammarNote.id` (hand-authored + generated) in the Guide → Grammar tab.
| Grammar ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |
|---|---|---|---|---|---|---|---|
| `ser-vs-estar` | Ser vs Estar: Spanish Lesson #3 | BaseLang | 2016-04-15 | 4:38 | 129,144 | 785 | [watch](https://www.youtube.com/watch?v=tFXGCUi2yls) |
| `por-vs-para` | When to Use Por or Para in Spanish \| The Language Tutor *Lesson 47* | The Language Tutor - Spanish | 2019-12-01 | 14:01 | 261,212 | 7,552 | [watch](https://www.youtube.com/watch?v=02Am4LlPGjU) |
| `preterite-vs-imperfect` | Spanish Past Tense: When to Use the Preterite vs Imperfect | BaseLang | 2025-01-14 | 4:20 | 37,948 | — | [watch](https://www.youtube.com/watch?v=LvhO2-azzig) |
| `subjunctive-triggers` | Spanish Subjunctive Simplified For Beginners | BaseLang | 2022-01-14 | 9:58 | 72,998 | — | [watch](https://www.youtube.com/watch?v=pMAMDgNNPB0) |
| `reflexive-verbs` | Reflexive Verbs in Spanish \| The Language Tutor *Lesson 37* | The Language Tutor - Spanish | 2019-09-22 | 10:37 | 324,262 | 7,345 | [watch](https://www.youtube.com/watch?v=_uH_tosBLyo) |
| `object-pronouns` | Intro To Spanish Direct And Indirect Object Pronouns | BaseLang | 2022-01-12 | 9:44 | 169,076 | — | [watch](https://www.youtube.com/watch?v=gEe4NW1FXx4) |
| `gustar-like-verbs` | How to Use Gustar in Spanish \| The Language Tutor *Lesson 121* | The Language Tutor - Spanish | 2021-12-12 | 14:06 | 173,299 | — | [watch](https://www.youtube.com/watch?v=SAfXpyZlz-I) |
| `comparatives-superlatives` | Comparatives and Superlatives in Spanish \| The Language Tutor *Lesson 123* | The Language Tutor - Spanish | 2022-05-10 | 15:57 | 90,246 | 2,085 | [watch](https://www.youtube.com/watch?v=U74ClJsbfb0) |
| `conditional-if-clauses` | Si Clauses: The Spanish Hypothetical Explained | BaseLang | 2022-05-13 | 8:59 | 16,328 | — | [watch](https://www.youtube.com/watch?v=thvW8qVsqkE) |
| `commands-imperative` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
| `saber-vs-conocer` | _(unavailable — Private video)_ | — | — | — | — | — | [watch](https://www.youtube.com/watch?v=j87i7MVCvIE) |
| `double-negatives` | Understanding Negation Words in Spanish \| The Language Tutor *Lesson 67* | The Language Tutor - Spanish | 2020-04-26 | 21:36 | 150,024 | 3,888 | [watch](https://www.youtube.com/watch?v=Y887wmI0O_o) |
| `adjective-placement` | Adjectives and Nouns Working Together in Spanish \| The Language Tutor *Lesson 66* | The Language Tutor - Spanish | 2020-04-19 | 17:17 | 70,340 | 1,980 | [watch](https://www.youtube.com/watch?v=2B_TK_aun8E) |
| `tener-expressions` | The Verb Tener: Spanish Lesson #17 | BaseLang | 2016-05-03 | 4:16 | 7,948 | 76 | [watch](https://www.youtube.com/watch?v=189Qg68VCmo) |
| `personal-a` | Learn Spanish-Preposition "A" vs. Personal “A” | castellano4U | 2017-04-04 | 11:45 | 1,976 | — | [watch](https://www.youtube.com/watch?v=B38CwLxgmOc) |
| `relative-pronouns` | How to Use Relative Pronouns in Spanish \| The Language Tutor *Lesson 73* | The Language Tutor - Spanish | 2020-06-07 | 11:02 | 57,363 | — | [watch](https://www.youtube.com/watch?v=fnD1VaLpsCA) |
| `future-vs-ir-a` | Future Tense in Spanish: 3 Ways To Speak About The Future | BaseLang | 2022-01-05 | 9:57 | 44,300 | — | [watch](https://www.youtube.com/watch?v=2nxynOxr_h4) |
| `accent-marks-stress` | Spanish Accent Marks: When and How to Use Them | The Language Tutor - Spanish | 2020-06-14 | 14:09 | 98,134 | 3,242 | [watch](https://www.youtube.com/watch?v=mXro8ngx07A) |
| `se-constructions` | Understanding "SE" in Spanish: Reflexive, Passive, and Impersonal Constructions EXPLAINED | Learn Spanish with Spanish55 | 2024-08-16 | 3:57 | 1,271 | 61 | [watch](https://www.youtube.com/watch?v=ndxsrGD7b-8) |
| `estar-gerund-progressive` | How to Use Gerunds "-ing" in Spanish \| The Language Tutor *Lesson 113* | The Language Tutor - Spanish | 2021-06-20 | 10:29 | 54,552 | 2,091 | [watch](https://www.youtube.com/watch?v=s6HeVBv-ctM) |
| `spanish-suffixes` | Diminutives and Augmentatives in Spanish \| The Language Tutor *Lesson 78* | The Language Tutor - Spanish | 2020-07-12 | 17:03 | 42,033 | — | [watch](https://www.youtube.com/watch?v=o88gkstA0ds) |
| `common-irregular-verbs` | Irregular Verbs in Spanish \| The Language Tutor *Lesson 21* | The Language Tutor - Spanish | 2019-06-09 | 12:21 | 301,865 | 6,338 | [watch](https://www.youtube.com/watch?v=l0rOmomHSxg) |
| `types-of-irregular-verbs` | _(unavailable — The following content is not available on this app)_ | — | — | — | — | — | [watch](https://www.youtube.com/watch?v=-XogD_S7pY4) |
| `present-indicative-conjugation` | Spanish Present Tense: Regular -AR -ER -IR verb conjugation | Learn Spanish with Spanish55 | 2019-10-18 | 7:14 | 51,288 | 233 | [watch](https://www.youtube.com/watch?v=8HWXJjxvOTE) |
| `articles-and-gender` | Definite and Indefinite Articles in Spanish \| The Language Tutor *Lesson 11* | The Language Tutor - Spanish | 2019-03-31 | 5:37 | 355,893 | 7,396 | [watch](https://www.youtube.com/watch?v=YeTIwDcKwZ4) |
| `possessive-adjectives` | Possessive Adjectives in Spanish | BaseLang | 2022-06-27 | 13:40 | 15,925 | — | [watch](https://www.youtube.com/watch?v=Y4Aw1jy0ohE) |
| `demonstrative-adjectives` | How to Use This, These, That and Those in Spanish \| The Language Tutor *Lesson 56* | The Language Tutor - Spanish | 2020-02-02 | 13:34 | 185,671 | 6,689 | [watch](https://www.youtube.com/watch?v=g4UzE8c2wik) |
| `greetings-farewells` | Every Spanish Greeting You Need (Formal, Casual & Slang) | The Language Tutor - Spanish | 2019-03-10 | 9:54 | 673,751 | 17,161 | [watch](https://www.youtube.com/watch?v=AqfQQZVmTUw) |
| `poder-infinitive` | Spanish - The Verb “Poder“ Explained In 3 Minutes | TheLanguageBro | 2023-07-01 | 3:16 | 4,913 | 106 | [watch](https://www.youtube.com/watch?v=hCUbz5942EY) |
| `al-del-contractions` | Spanish Contractions AL and DEL \| The Language Tutor * Lesson 15 * | The Language Tutor - Spanish | 2019-04-28 | 3:34 | 211,085 | 5,457 | [watch](https://www.youtube.com/watch?v=nWPZZWIwWxg) |
| `prepositional-pronouns` | Mastering Prepositional Pronouns in Spanish \| The Language Tutor *Lesson 71* | The Language Tutor - Spanish | 2020-05-24 | 13:34 | 93,929 | — | [watch](https://www.youtube.com/watch?v=quzRXk0oKp8) |
| `irregular-yo-verbs` | What are YO GO verbs in Spanish? | BaseLang | 2022-04-21 | 5:08 | 17,894 | — | [watch](https://www.youtube.com/watch?v=bM9NLgaeUvw) |
| `stem-changing-verbs` | Stem Changing Verbs in Spanish: Explained For Beginners | BaseLang | 2022-05-17 | 7:31 | 59,959 | — | [watch](https://www.youtube.com/watch?v=WB2ThQauaWo) |
| `stressed-possessives` | Learning Possessive Pronouns in Spanish \| The Language Tutor *Lesson 68* | The Language Tutor - Spanish | 2020-05-03 | 10:01 | 129,223 | 3,671 | [watch](https://www.youtube.com/watch?v=OL86D_omkSQ) |
| `present-perfect-tense` | Present Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 55* | The Language Tutor - Spanish | 2020-01-29 | 18:09 | 273,176 | 6,676 | [watch](https://www.youtube.com/watch?v=-uHwV5Lu310) |
| `future-perfect-tense` | Understanding the Future Perfect Indicative in Spanish \| The Language Tutor *Lesson 62* | The Language Tutor - Spanish | 2020-03-22 | 7:59 | 58,757 | 1,547 | [watch](https://www.youtube.com/watch?v=6cgc5ENNbR4) |
@@ -0,0 +1,95 @@
import XCTest
/// Screenshot every chapter of the textbook one top + one bottom frame each
/// so you can visually audit parsing / rendering issues across all 30 chapters.
final class AllChaptersScreenshotTests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = true
}
func testScreenshotEveryChapter() throws {
let app = XCUIApplication()
app.launchArguments += ["-onboardingComplete", "YES"]
app.launch()
let courseTab = app.tabBars.buttons["Course"]
XCTAssertTrue(courseTab.waitForExistence(timeout: 5))
courseTab.tap()
let textbookRow = app.buttons.containing(NSPredicate(
format: "label CONTAINS[c] 'Complete Spanish'"
)).firstMatch
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5))
textbookRow.tap()
// NOTE: SwiftUI List preserves scroll position across navigation pushes,
// so visiting chapters in-order means the next one is already visible
// after we return from the previous one. No need to reset.
attach(app, name: "00-chapter-list-top")
for chapter in 1...30 {
guard let row = findChapterRow(app: app, chapter: chapter) else {
XCTFail("Chapter \(chapter) row not reachable")
continue
}
row.tap()
// Chapter body wait until the chapter's title appears as a nav bar label
_ = app.navigationBars.firstMatch.waitForExistence(timeout: 3)
attach(app, name: String(format: "ch%02d-top", chapter))
// One big scroll to sample the bottom of the chapter
dragFullScreen(app, direction: .up)
dragFullScreen(app, direction: .up)
attach(app, name: String(format: "ch%02d-bottom", chapter))
tapNavBack(app)
// Small settle wait
_ = app.navigationBars.firstMatch.waitForExistence(timeout: 2)
}
}
// MARK: - Helpers
private enum DragDirection { case up, down }
private func dragFullScreen(_ app: XCUIApplication, direction: DragDirection) {
let top = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12))
let bot = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.88))
switch direction {
case .up: bot.press(forDuration: 0.1, thenDragTo: top)
case .down: top.press(forDuration: 0.1, thenDragTo: bot)
}
}
private func findChapterRow(app: XCUIApplication, chapter: Int) -> XCUIElement? {
// Chapter row accessibility label: "<n>, <title>, ..." (SwiftUI composes
// label from inner Texts). Match by starting number.
let predicate = NSPredicate(format: "label BEGINSWITH %@", "\(chapter),")
let row = app.buttons.matching(predicate).firstMatch
if row.exists && row.isHittable { return row }
// Scroll down up to 8 times searching for the row chapters visited
// in order, so usually 02 swipes suffice.
for _ in 0..<8 {
if row.exists && row.isHittable { return row }
dragFullScreen(app, direction: .up)
}
return row.exists ? row : nil
}
private func tapNavBack(_ app: XCUIApplication) {
let back = app.navigationBars.buttons.firstMatch
if back.exists && back.isHittable { back.tap() }
}
private func attach(_ app: XCUIApplication, name: String) {
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
@@ -0,0 +1,66 @@
import XCTest
final class StemChangeToggleTests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testStemChangeConjugationToggle() throws {
let app = XCUIApplication()
app.launchArguments += ["-onboardingComplete", "YES"]
app.launch()
// Course LanGo Beginner I Week 4 E-IE stem-changing verbs
app.tabBars.buttons["Course"].tap()
// Locate the E-IE deck row. Deck titles appear as static text / button.
// Scroll until visible, then tap.
let deckPredicate = NSPredicate(format: "label CONTAINS[c] 'E-IE stem changing verbs' AND NOT label CONTAINS[c] 'REVÉS'")
let deckRow = app.buttons.matching(deckPredicate).firstMatch
let listRef = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
let topRef = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.10))
for _ in 0..<12 {
if deckRow.exists && deckRow.isHittable { break }
listRef.press(forDuration: 0.1, thenDragTo: topRef)
}
XCTAssertTrue(deckRow.waitForExistence(timeout: 3), "E-IE deck row missing")
deckRow.tap()
attach(app, name: "01-deck-top")
// Tap "Show conjugation" on the first card
let showBtn = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Show conjugation'")).firstMatch
XCTAssertTrue(showBtn.waitForExistence(timeout: 3), "Show conjugation button missing")
showBtn.tap()
// Wait for the conjugation rows + animation to settle.
let yoLabel = app.staticTexts["yo"].firstMatch
XCTAssertTrue(yoLabel.waitForExistence(timeout: 3), "yo row not rendered")
// Give the transition time to complete before snapshotting.
Thread.sleep(forTimeInterval: 0.6)
attach(app, name: "02-conjugation-open")
// Also confirm all expected person labels are rendered.
for person in ["yo", "", "nosotros"] {
XCTAssertTrue(
app.staticTexts[person].firstMatch.exists,
"missing conjugation row for \(person)"
)
}
// Tap again to hide
let hideBtn = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Hide conjugation'")).firstMatch
XCTAssertTrue(hideBtn.waitForExistence(timeout: 2))
hideBtn.tap()
}
private func attach(_ app: XCUIApplication, name: String) {
let s = app.screenshot()
let a = XCTAttachment(screenshot: s)
a.name = name
a.lifetime = .keepAlways
add(a)
}
}
@@ -0,0 +1,80 @@
import XCTest
final class TextbookFlowUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testTextbookFlow() throws {
let app = XCUIApplication()
// Skip onboarding via defaults (already set by run script, but harmless to override)
app.launchArguments += ["-onboardingComplete", "YES"]
app.launch()
// Dashboard should be default tab. Switch to Course.
let courseTab = app.tabBars.buttons["Course"]
XCTAssertTrue(courseTab.waitForExistence(timeout: 5), "Course tab missing")
courseTab.tap()
// Attach a screenshot of the Course list
attach(app, name: "01-course-list")
// Tap the Textbook entry
let textbookRow = app.buttons.containing(NSPredicate(
format: "label CONTAINS[c] 'Complete Spanish'"
)).firstMatch
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5), "Textbook row missing in Course")
textbookRow.tap()
attach(app, name: "02-textbook-chapter-list")
// Tap chapter 1 should navigate to reader
let chapterOneRow = app.buttons.containing(NSPredicate(
format: "label CONTAINS[c] 'Nouns, Articles'"
)).firstMatch
XCTAssertTrue(chapterOneRow.waitForExistence(timeout: 5), "Chapter 1 row missing")
chapterOneRow.tap()
attach(app, name: "03-chapter-body")
// Find the first exercise link ("Exercise 1.1")
let exerciseRow = app.buttons.containing(NSPredicate(
format: "label CONTAINS[c] 'Exercise 1.1'"
)).firstMatch
XCTAssertTrue(exerciseRow.waitForExistence(timeout: 5), "Exercise 1.1 link missing")
exerciseRow.tap()
attach(app, name: "04-exercise-view")
// Check presence of input fields: at least a few numbered prompts
// Text fields use SwiftUI placeholder "Your answer"
let firstField = app.textFields["Your answer"].firstMatch
XCTAssertTrue(firstField.waitForExistence(timeout: 5), "No input fields rendered for exercise")
firstField.tap()
firstField.typeText("el")
attach(app, name: "05-exercise-typed-el")
// Tap Check answers
let checkButton = app.buttons["Check answers"]
XCTAssertTrue(checkButton.waitForExistence(timeout: 3), "Check answers button missing")
checkButton.tap()
attach(app, name: "06-exercise-graded")
// The first answer to Exercise 1.1 is "el" we should see the first prompt
// graded correct. Iterating too deeply is fragile; just take a screenshot
// and check for presence of either a checkmark-like label or "Try again".
let tryAgain = app.buttons["Try again"]
XCTAssertTrue(tryAgain.waitForExistence(timeout: 3), "Grading did not complete")
}
private func attach(_ app: XCUIApplication, name: String) {
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
@@ -0,0 +1,53 @@
import XCTest
final class VocabGridTests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
/// Verifies the chapter reader renders vocab tables as a paired SpanishEnglish grid.
func testChapter4VocabGrid() throws {
let app = XCUIApplication()
app.launchArguments += ["-onboardingComplete", "YES"]
app.launch()
app.tabBars.buttons["Course"].tap()
let textbookRow = app.buttons.containing(NSPredicate(
format: "label CONTAINS[c] 'Complete Spanish'"
)).firstMatch
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5))
textbookRow.tap()
let ch4 = app.buttons["textbook-chapter-row-4"]
XCTAssertTrue(ch4.waitForExistence(timeout: 3))
ch4.tap()
attach(app, name: "01-ch4-top")
// Tap the first vocab disclosure "Vocabulary (N items)"
let vocabButton = app.buttons.matching(NSPredicate(
format: "label BEGINSWITH 'Vocabulary ('"
)).firstMatch
XCTAssertTrue(vocabButton.waitForExistence(timeout: 3))
vocabButton.tap()
Thread.sleep(forTimeInterval: 0.4)
attach(app, name: "02-ch4-vocab-open")
// Scroll a little and screenshot a deeper vocab numbers table is
// typically a few screens down in chapter 4.
app.swipeUp(velocity: .fast)
app.swipeUp(velocity: .fast)
attach(app, name: "03-ch4-deeper")
}
private func attach(_ app: XCUIApplication, name: String) {
let s = app.screenshot()
let a = XCTAttachment(screenshot: s)
a.name = name
a.lifetime = .keepAlways
add(a)
}
}
+7 -9
View File
@@ -41,21 +41,19 @@ struct CombinedProvider: TimelineProvider {
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
guard let localURL = SharedStore.localStoreURL() else { return nil }
// MUST declare all 6 local entities to match the main app's schema.
// Declaring a subset would cause SwiftData to destructively migrate the store
// on open, dropping the entities not listed here.
// Open the store with the SAME schema as the main app. A subset schema
// would make SwiftData destructively migrate the store on open and drop
// every unlisted table (this is how widget refreshes kept wiping the
// bundled Book rows, and TextbookChapter before them).
let schema = Schema(SharedStore.localSchemaModels)
let config = ModelConfiguration(
"local",
schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
schema: schema,
url: localURL,
cloudKitDatabase: .none
)
guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
for: schema,
configurations: config
) else { return nil }
+7 -9
View File
@@ -32,21 +32,19 @@ struct WordOfDayProvider: TimelineProvider {
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
guard let localURL = SharedStore.localStoreURL() else { return nil }
// MUST declare all 6 local entities to match the main app's schema.
// Declaring a subset would cause SwiftData to destructively migrate the store
// on open, dropping the entities not listed here.
// Open the store with the SAME schema as the main app. A subset schema
// would make SwiftData destructively migrate the store on open and drop
// every unlisted table (this is how widget refreshes kept wiping the
// bundled Book rows, and TextbookChapter before them).
let schema = Schema(SharedStore.localSchemaModels)
let config = ModelConfiguration(
"local",
schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
schema: schema,
url: localURL,
cloudKitDatabase: .none
)
guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
for: schema,
configurations: config
) else { return nil }
+1
View File
@@ -0,0 +1 @@
build/

Some files were not shown because too many files have changed in this diff Show More