44 Commits

Author SHA1 Message Date
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
97 changed files with 55793 additions and 11248 deletions
+1
View File
@@ -50,6 +50,7 @@ epub_extract/
# Scripts are committed; their generated outputs are not. # Scripts are committed; their generated outputs are not.
Conjuga/Scripts/textbook/*.json Conjuga/Scripts/textbook/*.json
Conjuga/Scripts/textbook/review.html Conjuga/Scripts/textbook/review.html
Conjuga/Scripts/textbook/paired_vocab_llm/
# Note: the app-bundle copies (Conjuga/Conjuga/textbook_{data,vocab}.json) # Note: the app-bundle copies (Conjuga/Conjuga/textbook_{data,vocab}.json)
# ARE committed so `xcodebuild` works on a fresh clone without first running # ARE committed so `xcodebuild` works on a fresh clone without first running
# the pipeline. They're regenerated from the scripts when content changes. # the pipeline. They're regenerated from the scripts when content changes.
+241 -260
View File
@@ -8,83 +8,103 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; }; 00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; };
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; };
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; }; 0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; }; 0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */; };
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */; };
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; }; 13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; }; 1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
1B0B3B2C771AD72E25B3493C /* StemChangeToggleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F08E1DC6932D9EA1D380913 /* StemChangeToggleTests.swift */; };
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; }; 1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; };
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; }; 218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; }; 261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; }; 27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; };
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */; }; 28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */; };
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; }; 2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; }; 2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */; };
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A661ADF1141176EE96774138 /* BookSpeechController.swift */; };
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; }; 33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; }; 352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; }; 35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10603F454E54341AA4B9931 /* ConversationService.swift */; }; 362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; }; 36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; }; 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; }; 39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3698CE7ACF148318615293E /* VocabReviewView.swift */; };
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; }; 3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; };
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; }; 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */; };
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; }; 46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; }; 48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; };
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; }; 4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2179562E54E148C98219D /* ListeningView.swift */; }; 4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */; };
4E00225D668FDFA3026B7627 /* BookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3475931F1AD16054741E65 /* BookChapterListView.swift */; };
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; }; 50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; }; 519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; }; 51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A649B04B8B3C49419AD9219C /* ClozeView.swift */; };
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; }; 53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; }; 5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ACC4C35491174257770941 /* VerbReviewStore.swift */; };
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; }; 5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; };
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; }; 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */; };
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; }; 615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */; }; 64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD4AF96186662567525F8C4 /* BookReaderView.swift */; };
65382875879BD537F5358381 /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */; };
6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */; };
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; }; 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */; };
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; }; 6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; }; 6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */; };
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; }; 728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; }; 760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.swift */; };
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; }; 7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
7A1B2C3D4E5F60718293A4B5 /* textbook_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293A4C6 /* textbook_data.json */; }; 81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; };
7A1B2C3D4E5F60718293A4B6 /* textbook_vocab.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293A4C7 /* textbook_vocab.json */; };
7A1B2C3D4E5F60718293AA01 /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA11 /* TextbookChapterListView.swift */; };
7A1B2C3D4E5F60718293AA02 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA12 /* TextbookChapterView.swift */; };
7A1B2C3D4E5F60718293AA03 /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA13 /* TextbookExerciseView.swift */; };
7A1B2C3D4E5F60718293AA04 /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA14 /* AnswerChecker.swift */; };
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; }; 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; }; 82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; }; 84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */; }; 8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */; };
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327659ABFD524514B6D2D505 /* StoryGenerator.swift */; }; 8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; };
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; }; 8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */; }; 8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; };
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; }; 90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */; };
943728CD3E65FE6CCADB05EE /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3A181BF2399D34C23DA933 /* StemChangeConjugationView.swift */; };
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; }; 943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */; }; 97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */ = {isa = PBXBuildFile; fileRef = 3644B5ED77F29A65877D926A /* reflexive_verbs.json */; };
96A3E5FA8EC63123D97365E1 /* TextbookFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA84E15880A9D56DE18F33 /* TextbookFlowUITests.swift */; };
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; }; 97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; };
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; }; 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
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 */; }; A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.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 */; };
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */; };
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; };
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; }; B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.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 */; }; BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
BC662C36AC503E00A977CEC1 /* VocabGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584E0FDA939E3B82EECA4B5 /* VocabGridTests.swift */; };
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; }; BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; }; C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; }; C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; }; C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; }; C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */; }; C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */; };
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; }; C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; }; CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */; };
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; }; CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; }; D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; }; D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
@@ -92,20 +112,20 @@
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; }; D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; }; D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; }; DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; };
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; }; DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */; };
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; }; E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; }; E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
E82C743EB1FDF6B67ED22EAD /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6153A5C7241C1AB0373AA17 /* Foundation.framework */; };
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; }; E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D535EF6988A24B47B70209A2 /* PronunciationService.swift */; };
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; }; ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; }; F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 539736EB2AB8D149ED0F9C39 /* textbook_data.json */; };
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */ = {isa = PBXBuildFile; fileRef = 6658C35E454C137B53FC05A4 /* youtube_videos.json */; };
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; }; F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
F7E459C46F25A8A45D7E0DFB /* AllChaptersScreenshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A630C74D28CE1B280C9F296 /* AllChaptersScreenshotTests.swift */; };
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; }; F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; }; FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -116,13 +136,6 @@
remoteGlobalIDString = F73909B4044081DB8F6272AF; remoteGlobalIDString = F73909B4044081DB8F6272AF;
remoteInfo = ConjugaWidgetExtension; remoteInfo = ConjugaWidgetExtension;
}; };
6E1F966015DA38BD4E3CE8AF /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AB7396D9C3E14B65B5238368 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 96127FACA68AE541F5C0F8BC;
remoteInfo = Conjuga;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@@ -140,33 +153,40 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; }; 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleGenerator.swift; sourceTree = "<group>"; };
0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; }; 0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; };
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; }; 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; };
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; }; 102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; }; 10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; }; 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; }; 16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; };
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; }; 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.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>"; }; 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>"; }; 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>"; }; 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>"; }; 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; }; 1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookVoicePickerSheet.swift; sourceTree = "<group>"; };
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; }; 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; }; 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; }; 1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
27B2A75AAF79A9402AAF3F57 /* ConjugaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConjugaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 20D1904DF07E0A6816134CF3 /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.swift; sourceTree = "<group>"; };
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; }; 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; }; 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; }; 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; };
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
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>"; }; 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>"; }; 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>"; }; 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>"; }; 3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
@@ -175,9 +195,13 @@
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; }; 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.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>"; }; 58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; }; 5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; }; 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
@@ -185,65 +209,72 @@
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; }; 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.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>"; }; 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
6584E0FDA939E3B82EECA4B5 /* VocabGridTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabGridTests.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>"; }; 69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; }; 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; }; 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.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>"; }; 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>"; }; 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>"; }; 777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
7A1B2C3D4E5F60718293A4C6 /* textbook_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_data.json; sourceTree = "<group>"; }; 79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
7A1B2C3D4E5F60718293A4C7 /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; };
7A1B2C3D4E5F60718293AA11 /* TextbookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterListView.swift; sourceTree = "<group>"; };
7A1B2C3D4E5F60718293AA12 /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; };
7A1B2C3D4E5F60718293AA13 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
7A1B2C3D4E5F60718293AA14 /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; };
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; }; 80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; }; 833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; }; 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; }; 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
8A630C74D28CE1B280C9F296 /* AllChaptersScreenshotTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AllChaptersScreenshotTests.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>"; };
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; }; 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyView.swift; sourceTree = "<group>"; };
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; }; 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; }; 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
8F08E1DC6932D9EA1D380913 /* StemChangeToggleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StemChangeToggleTests.swift; sourceTree = "<group>"; }; 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = "<group>"; };
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; }; 9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; }; A2ACC4C35491174257770941 /* VerbReviewStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; }; A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
A6153A5C7241C1AB0373AA17 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; }; A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; }; A661ADF1141176EE96774138 /* BookSpeechController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = youtube_videos.md; sourceTree = "<group>"; };
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = "<group>"; };
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; };
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; }; BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; }; C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; }; CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
CEEA84E15880A9D56DE18F33 /* TextbookFlowUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextbookFlowUITests.swift; sourceTree = "<group>"; };
CF3A181BF2399D34C23DA933 /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; }; CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; }; D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; }; D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; }; DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; }; DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; }; DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; }; DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; }; DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabStudyGroup.swift; sourceTree = "<group>"; };
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; }; E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; }; E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; }; E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; }; E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; }; EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */ = {isa = PBXFileReference; includeInIndex = 1; path = "book_olly-vol2.json"; sourceTree = "<group>"; };
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; }; EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.swift; sourceTree = "<group>"; };
EDD4AF96186662567525F8C4 /* BookReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookReaderView.swift; sourceTree = "<group>"; };
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.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>"; }; FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.swift; sourceTree = "<group>"; };
FF3475931F1AD16054741E65 /* BookChapterListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -260,14 +291,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */, BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
); 362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */,
runOnlyForDeploymentPostprocessing = 0;
};
C5C1BB325D49EE6ED3AC3D5F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E82C743EB1FDF6B67ED22EAD /* Foundation.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -282,14 +306,17 @@
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */, 9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */, 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
BC273716CD14A99EFF8206CA /* course_data.json */, BC273716CD14A99EFF8206CA /* course_data.json */,
7A1B2C3D4E5F60718293A4C6 /* textbook_data.json */,
7A1B2C3D4E5F60718293A4C7 /* textbook_vocab.json */,
7E6AF62A3A949630E067DC22 /* Info.plist */, 7E6AF62A3A949630E067DC22 /* Info.plist */,
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
539736EB2AB8D149ED0F9C39 /* textbook_data.json */,
3540936F058728CFD87B1A1E /* textbook_vocab.json */,
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */,
353C5DE41FD410FA82E3AED7 /* Models */, 353C5DE41FD410FA82E3AED7 /* Models */,
1994867BC8E985795A172854 /* Services */, 1994867BC8E985795A172854 /* Services */,
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
3C75490F53C34A37084FF478 /* ViewModels */, 3C75490F53C34A37084FF478 /* ViewModels */,
A81CA75762B08D35D5B7A44D /* Views */, A81CA75762B08D35D5B7A44D /* Views */,
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */,
); );
path = Conjuga; path = Conjuga;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -297,8 +324,9 @@
0931AEB5B728C3A03F06A1CA /* Settings */ = { 0931AEB5B728C3A03F06A1CA /* Settings */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */,
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */,
BCCC95A95581458E068E0484 /* SettingsView.swift */, BCCC95A95581458E068E0484 /* SettingsView.swift */,
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
); );
path = Settings; path = Settings;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -316,29 +344,48 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */, 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
3A96C065B8787DEC6818E497 /* ConversationService.swift */,
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */, DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */, DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
7A1B2C3D4E5F60718293AA14 /* AnswerChecker.swift */, 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */, 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */, 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */, 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
E10603F454E54341AA4B9931 /* ConversationService.swift */, 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
777C696A841803D5B775B678 /* ReferenceStore.swift */, 777C696A841803D5B775B678 /* ReferenceStore.swift */,
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */,
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */, CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
49E3AD244327CBF24B7A2752 /* SpeechService.swift */, 49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */, 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */, A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */, E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */,
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */,
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */, 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */, D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
1ECAF79E2138DF73BB1F6403 /* Vocab */ = {
isa = PBXGroup;
children = (
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
);
name = Vocab;
path = Vocab;
sourceTree = "<group>";
};
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = { 29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -351,6 +398,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0313D24F96E6A0039C34341F /* DailyLog.swift */, 0313D24F96E6A0039C34341F /* DailyLog.swift */,
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */, 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
626873572466403C0288090D /* QuizType.swift */, 626873572466403C0288090D /* QuizType.swift */,
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */, 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
@@ -358,7 +406,8 @@
3BC3247457109FC6BF00D85B /* TenseInfo.swift */, 3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
DAFE27F29412021AEC57E728 /* TestResult.swift */, DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */, E536AD1180FE10576EAC884A /* UserProgress.swift */,
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */, A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -384,6 +433,16 @@
path = ViewModels; path = ViewModels;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
43E4D263B0AF47E401A51601 /* Stories */ = {
isa = PBXGroup;
children = (
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */,
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */,
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */,
);
path = Stories;
sourceTree = "<group>";
};
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = { 4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -404,30 +463,45 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */, 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */, 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */, 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */,
1F842EB5E566C74658D918BB /* HandwritingView.swift */, 1F842EB5E566C74658D918BB /* HandwritingView.swift */,
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */, DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */, 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */, 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */, 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
10C16AA6022E4742898745CE /* TypingView.swift */, 10C16AA6022E4742898745CE /* TypingView.swift */,
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
8FB89F19B33894DDF27C8EC2 /* Chat */,
895E547BEFB5D0FBF676BE33 /* Lyrics */, 895E547BEFB5D0FBF676BE33 /* Lyrics */,
8A1DED0596E04DDE9536A9A9 /* Stories */, 43E4D263B0AF47E401A51601 /* Stories */,
DFD75E32A53845A693D98F48 /* Chat */, 74AC8A0D381958D2A14316C3 /* Books */,
02B2179562E54E148C98219D /* ListeningView.swift */, 1ECAF79E2138DF73BB1F6403 /* Vocab */,
A649B04B8B3C49419AD9219C /* ClozeView.swift */,
); );
path = Practice; path = Practice;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
74AC8A0D381958D2A14316C3 /* Books */ = {
isa = PBXGroup;
children = (
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */,
FF3475931F1AD16054741E65 /* BookChapterListView.swift */,
EDD4AF96186662567525F8C4 /* BookReaderView.swift */,
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */,
);
name = Books;
path = Books;
sourceTree = "<group>";
};
8102F7FA5BFE6D38B2212AD3 /* Guide */ = { 8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */, 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */, 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */, 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */,
); );
path = Guide; path = Guide;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -443,14 +517,13 @@
path = Lyrics; path = Lyrics;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
8A1DED0596E04DDE9536A9A9 /* Stories */ = { 8FB89F19B33894DDF27C8EC2 /* Chat */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */, 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */,
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */, 79576893566932D2BE207528 /* ChatView.swift */,
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */,
); );
path = Stories; path = Chat;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A591A3B6F1F13D23D68D7A9D = { A591A3B6F1F13D23D68D7A9D = {
@@ -460,8 +533,6 @@
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */, 4B183AB0C56BC2EC302531E7 /* ConjugaWidget */,
F7D740BB7D1E23949D4C1AE5 /* Packages */, F7D740BB7D1E23949D4C1AE5 /* Packages */,
F605D24E5EA11065FD18AF7E /* Products */, F605D24E5EA11065FD18AF7E /* Products */,
B442229C0A26C1D531472C7D /* Frameworks */,
C77B065CF67D1F5128E10CC7 /* ConjugaUITests */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -481,14 +552,6 @@
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
B442229C0A26C1D531472C7D /* Frameworks */ = {
isa = PBXGroup;
children = (
E772BA9C3FF67FEA9A034B4B /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
BA34B77A38B698101DBBE241 /* Dashboard */ = { BA34B77A38B698101DBBE241 /* Dashboard */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -501,62 +564,26 @@
BE5A40BAC9DD6884C58A2096 /* Course */ = { BE5A40BAC9DD6884C58A2096 /* Course */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */,
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */, 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
833516C5D57F164C8660A479 /* CourseView.swift */, 833516C5D57F164C8660A479 /* CourseView.swift */,
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */, 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
7A1B2C3D4E5F60718293AA11 /* TextbookChapterListView.swift */, F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
7A1B2C3D4E5F60718293AA12 /* TextbookChapterView.swift */, 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
7A1B2C3D4E5F60718293AA13 /* TextbookExerciseView.swift */, 39908548430FDF01D76201FB /* TextbookChapterView.swift */,
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */, 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */, 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */, 8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */,
CF3A181BF2399D34C23DA933 /* StemChangeConjugationView.swift */,
); );
path = Course; path = Course;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
BFC1AEBE02CE22E6474FFEA6 /* Utilities */ = {
isa = PBXGroup;
children = (
);
path = Utilities;
sourceTree = "<group>";
};
C77B065CF67D1F5128E10CC7 /* ConjugaUITests */ = {
isa = PBXGroup;
children = (
CEEA84E15880A9D56DE18F33 /* TextbookFlowUITests.swift */,
8A630C74D28CE1B280C9F296 /* AllChaptersScreenshotTests.swift */,
8F08E1DC6932D9EA1D380913 /* StemChangeToggleTests.swift */,
6584E0FDA939E3B82EECA4B5 /* VocabGridTests.swift */,
);
name = ConjugaUITests;
path = ConjugaUITests;
sourceTree = "<group>";
};
DFD75E32A53845A693D98F48 /* Chat */ = {
isa = PBXGroup;
children = (
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */,
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */,
);
path = Chat;
sourceTree = "<group>";
};
E772BA9C3FF67FEA9A034B4B /* iOS */ = {
isa = PBXGroup;
children = (
A6153A5C7241C1AB0373AA17 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
F605D24E5EA11065FD18AF7E /* Products */ = { F605D24E5EA11065FD18AF7E /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
16C1F74196C3C5628953BE3F /* Conjuga.app */, 16C1F74196C3C5628953BE3F /* Conjuga.app */,
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */, 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */,
27B2A75AAF79A9402AAF3F57 /* ConjugaUITests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -589,29 +616,12 @@
name = Conjuga; name = Conjuga;
packageProductDependencies = ( packageProductDependencies = (
BCCBABD74CADDB118179D8E9 /* SharedModels */, BCCBABD74CADDB118179D8E9 /* SharedModels */,
08D6313690BEE4E2F18EADC3 /* YouTubeKit */,
); );
productName = Conjuga; productName = Conjuga;
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */; productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
C6CC399BFD5A2574CB9956B4 /* ConjugaUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = F454EA7279A44C5E151F71BA /* Build configuration list for PBXNativeTarget "ConjugaUITests" */;
buildPhases = (
66589E8F78971725CA2066ED /* Sources */,
C5C1BB325D49EE6ED3AC3D5F /* Frameworks */,
425DC31DA6EF2C4C7A873DAA /* Resources */,
);
buildRules = (
);
dependencies = (
04C7E3C8079DE56024C2154E /* PBXTargetDependency */,
);
name = ConjugaUITests;
productName = ConjugaUITests;
productReference = 27B2A75AAF79A9402AAF3F57 /* ConjugaUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */ = { F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = EA7E12CF28EB750C2B8BB2F1 /* Build configuration list for PBXNativeTarget "ConjugaWidgetExtension" */; buildConfigurationList = EA7E12CF28EB750C2B8BB2F1 /* Build configuration list for PBXNativeTarget "ConjugaWidgetExtension" */;
@@ -651,7 +661,6 @@
}; };
}; };
buildConfigurationList = F011A5DA3101F6F7CA7D2D95 /* Build configuration list for PBXProject "Conjuga" */; buildConfigurationList = F011A5DA3101F6F7CA7D2D95 /* Build configuration list for PBXProject "Conjuga" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en; developmentRegion = en;
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
knownRegions = ( knownRegions = (
@@ -661,6 +670,7 @@
mainGroup = A591A3B6F1F13D23D68D7A9D; mainGroup = A591A3B6F1F13D23D68D7A9D;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */,
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */, 548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
@@ -670,19 +680,11 @@
targets = ( targets = (
96127FACA68AE541F5C0F8BC /* Conjuga */, 96127FACA68AE541F5C0F8BC /* Conjuga */,
F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */, F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */,
C6CC399BFD5A2574CB9956B4 /* ConjugaUITests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
425DC31DA6EF2C4C7A873DAA /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
B74A8384221C70A670B902D8 /* Resources */ = { B74A8384221C70A670B902D8 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -690,8 +692,12 @@
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */, F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */, CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */, 2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
7A1B2C3D4E5F60718293A4B5 /* textbook_data.json in Resources */, 97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
7A1B2C3D4E5F60718293A4B6 /* textbook_vocab.json in Resources */, F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */,
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */,
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */,
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -704,22 +710,29 @@
files = ( files = (
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */, 97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */, 261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */, CAC69045B74249F121643E88 /* AnswerReviewView.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 */, C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */,
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */, C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */, 8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */, F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
7A1B2C3D4E5F60718293AA01 /* TextbookChapterListView.swift in Sources */,
7A1B2C3D4E5F60718293AA02 /* TextbookChapterView.swift in Sources */,
7A1B2C3D4E5F60718293AA03 /* TextbookExerciseView.swift in Sources */,
7A1B2C3D4E5F60718293AA04 /* AnswerChecker.swift in Sources */,
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */, 1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */,
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */, BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */,
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */, 35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
C8C3880535008764B7117049 /* DataLoader.swift in Sources */, C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */, 84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */, D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */, A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */,
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */, 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */, F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */, 760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
@@ -727,6 +740,7 @@
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */, E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */, 33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */, 28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */,
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */, 519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */, B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */, 615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
@@ -739,8 +753,10 @@
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */, 352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */, 1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */, C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */,
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */, 53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */, DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */,
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */, FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */, 728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */, 51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
@@ -748,39 +764,49 @@
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */, 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */, D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */, 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */,
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */,
E814A9CF1067313F74B509C6 /* StoreInspector.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 */, 36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */,
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */, 6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */, D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */, AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */, 0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */, 46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
D7456B289D135CEB3A15122B /* TestResult.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 */, 27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */, 943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */, 50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */, 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */, 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */, 05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */, AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */,
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */, 6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */,
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */, 65382875879BD537F5358381 /* BookLibraryView.swift in Sources */,
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */, 4E00225D668FDFA3026B7627 /* BookChapterListView.swift in Sources */,
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */, 64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */,
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */, 33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */, 2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */, C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */, 419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */, 12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */, 5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */, 13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */,
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */, 6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */,
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */,
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */,
943728CD3E65FE6CCADB05EE /* StemChangeConjugationView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -798,26 +824,9 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
66589E8F78971725CA2066ED /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
96A3E5FA8EC63123D97365E1 /* TextbookFlowUITests.swift in Sources */,
F7E459C46F25A8A45D7E0DFB /* AllChaptersScreenshotTests.swift in Sources */,
1B0B3B2C771AD72E25B3493C /* StemChangeToggleTests.swift in Sources */,
BC662C36AC503E00A977CEC1 /* VocabGridTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
04C7E3C8079DE56024C2154E /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = Conjuga;
target = 96127FACA68AE541F5C0F8BC /* Conjuga */;
targetProxy = 6E1F966015DA38BD4E3CE8AF /* PBXContainerItemProxy */;
};
0B370CF10B68E386093E5BB2 /* PBXTargetDependency */ = { 0B370CF10B68E386093E5BB2 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */; target = F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */;
@@ -966,24 +975,6 @@
}; };
name = Release; name = Release;
}; };
A923186E44A25A8086B27A34 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = V3PF3M6B6U;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
PRODUCT_BUNDLE_IDENTIFIER = com.conjuga.app.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Conjuga;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
B9223DC55BB69E9AB81B59AE /* Debug */ = { B9223DC55BB69E9AB81B59AE /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -1049,23 +1040,6 @@
}; };
name = Debug; name = Debug;
}; };
DB8C0F513F77A50F2EF2D561 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = V3PF3M6B6U;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
PRODUCT_BUNDLE_IDENTIFIER = com.conjuga.app.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Conjuga;
};
name = Debug;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -1096,15 +1070,6 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
F454EA7279A44C5E151F71BA /* Build configuration list for PBXNativeTarget "ConjugaUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A923186E44A25A8086B27A34 /* Release */,
DB8C0F513F77A50F2EF2D561 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
@@ -1114,7 +1079,23 @@
}; };
/* End XCLocalSwiftPackageReference section */ /* 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 */ /* Begin XCSwiftPackageProductDependency section */
08D6313690BEE4E2F18EADC3 /* YouTubeKit */ = {
isa = XCSwiftPackageProductDependency;
package = E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */;
productName = YouTubeKit;
};
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = { 4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = SharedModels; 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
}
@@ -53,16 +53,6 @@
</BuildableReference> </BuildableReference>
</MacroExpansion> </MacroExpansion>
<Testables> <Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C77B065CF67D1F5128E10CC7"
BuildableName = "ConjugaUITests.xctest"
BlueprintName = "ConjugaUITests"
ReferencedContainer = "container:Conjuga.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables> </Testables>
<CommandLineArguments> <CommandLineArguments>
</CommandLineArguments> </CommandLineArguments>
+21 -16
View File
@@ -40,6 +40,9 @@ struct ConjugaApp: App {
@State private var syncMonitor = SyncStatusMonitor() @State private var syncMonitor = SyncStatusMonitor()
@State private var studyTimer = StudyTimerService() @State private var studyTimer = StudyTimerService()
@State private var dictionary = DictionaryService() @State private var dictionary = DictionaryService()
@State private var verbExampleCache = VerbExampleCache()
@State private var reflexiveStore = ReflexiveVerbStore()
@State private var youtubeVideoStore = YouTubeVideoStore()
let localContainer: ModelContainer let localContainer: ModelContainer
let cloudContainer: ModelContainer let cloudContainer: ModelContainer
@@ -67,16 +70,16 @@ struct ConjugaApp: App {
let cloudConfig = ModelConfiguration( let cloudConfig = ModelConfiguration(
"cloud", "cloud",
schema: Schema([ 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, TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
]), ]),
cloudKitDatabase: .private("iCloud.com.conjuga.app") cloudKitDatabase: .private("iCloud.com.conjuga.app")
) )
cloudContainer = try ModelContainer( cloudContainer = try ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self, for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self, TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
configurations: cloudConfig configurations: cloudConfig
) )
@@ -113,6 +116,9 @@ struct ConjugaApp: App {
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext }) .environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
.environment(studyTimer) .environment(studyTimer)
.environment(dictionary) .environment(dictionary)
.environment(verbExampleCache)
.environment(reflexiveStore)
.environment(youtubeVideoStore)
.task { .task {
let needsSeed = await DataLoader.needsSeeding(container: localContainer) let needsSeed = await DataLoader.needsSeeding(container: localContainer)
if needsSeed { if needsSeed {
@@ -135,6 +141,11 @@ struct ConjugaApp: App {
localContainer: localContainer, localContainer: localContainer,
cloudContainer: cloudContainer 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( WidgetDataService.update(
localContainer: localContainer, localContainer: localContainer,
cloudContainer: cloudContainer cloudContainer: cloudContainer
@@ -206,22 +217,16 @@ struct ConjugaApp: App {
} }
private static func makeLocalContainer(at url: URL) throws -> ModelContainer { 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( let localConfig = ModelConfiguration(
"local", "local",
schema: Schema([ schema: schema,
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
]),
url: url, url: url,
cloudKitDatabase: .none cloudKitDatabase: .none
) )
return try ModelContainer( return try ModelContainer(for: schema, configurations: localConfig)
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
configurations: localConfig
)
} }
private static func localStoreIsUsable(container: ModelContainer) -> Bool { private static func localStoreIsUsable(container: ModelContainer) -> Bool {
@@ -248,7 +253,7 @@ struct ConjugaApp: App {
/// Clears accumulated stale schema metadata from previous container configurations. /// Clears accumulated stale schema metadata from previous container configurations.
/// Bump the version number to force another reset if the schema changes again. /// Bump the version number to force another reset if the schema changes again.
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) { private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
let resetVersion = 3 // bump: SavedSong moved to cloud container let resetVersion = 5 // bump: Book/BookChapter added to local container
let key = "localStoreResetVersion" let key = "localStoreResetVersion"
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
+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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.conjuga.app.refresh</string>
</array>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -22,21 +26,13 @@
<string>1</string> <string>1</string>
<key>LSApplicationCategoryType</key> <key>LSApplicationCategoryType</key>
<string>public.app-category.education</string> <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> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string> <string>fetch</string>
<string>remote-notification</string> <string>remote-notification</string>
</array> </array>
<key>BGTaskSchedulerPermittedIdentifiers</key> <key>UILaunchScreen</key>
<array> <dict/>
<string>com.conjuga.app.refresh</string>
</array>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <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() let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
formatter.dateFormat = "yyyy-MM-dd" 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 { static func todayString() -> String {
@@ -43,8 +54,6 @@ final class DailyLog {
} }
static func date(from string: String) -> Date? { static func date(from string: String) -> Date? {
let formatter = DateFormatter() makeFormatter().date(from: string)
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: string)
} }
} }
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] ?? []
}
}
+27
View File
@@ -55,3 +55,30 @@ final class CourseReviewCard {
self.back = back 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)"
}
}
+85
View File
@@ -14,6 +14,7 @@ final class UserProgress {
var selectedLevel: String = "basic" var selectedLevel: String = "basic"
var showVosotros: Bool = true var showVosotros: Bool = true
var autoFillStem: Bool = false var autoFillStem: Bool = false
var showReflexiveVerbsOnly: Bool = false
// Legacy CloudKit array-backed fields retained for migration compatibility. // Legacy CloudKit array-backed fields retained for migration compatibility.
var enabledTenses: [String] = [] var enabledTenses: [String] = []
@@ -21,6 +22,10 @@ final class UserProgress {
var enabledTensesBlob: String = "" var enabledTensesBlob: String = ""
var unlockedBadgesBlob: String = "" var unlockedBadgesBlob: String = ""
// Multi-select level + irregularity filters (Issue #26).
var selectedLevelsBlob: String = ""
var enabledIrregularCategoriesBlob: String = ""
init() {} init() {}
var selectedVerbLevel: VerbLevel { var selectedVerbLevel: VerbLevel {
@@ -44,6 +49,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) { func setTenseEnabled(_ tenseId: String, enabled: Bool) {
var values = Set(enabledTenseIDs) var values = Set(enabledTenseIDs)
if enabled { if enabled {
@@ -54,12 +97,50 @@ final class UserProgress {
enabledTenseIDs = values.sorted() enabledTenseIDs = values.sorted()
} }
func setLevelEnabled(_ level: VerbLevel, enabled: Bool) {
var values = selectedVerbLevels
if enabled {
values.insert(level)
} else {
values.remove(level)
}
selectedVerbLevels = 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) { func unlockBadge(_ badgeId: String) {
var values = Set(unlockedBadgeIDs) var values = Set(unlockedBadgeIDs)
values.insert(badgeId) values.insert(badgeId)
unlockedBadgeIDs = values.sorted() 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() { func migrateLegacyStorageIfNeeded() {
if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty { if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty {
enabledTenseIDs = enabledTenses enabledTenseIDs = enabledTenses
@@ -67,6 +148,9 @@ final class UserProgress {
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty { if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
unlockedBadgeIDs = unlockedBadges unlockedBadgeIDs = unlockedBadges
} }
if selectedLevelsBlob.isEmpty, let legacy = VerbLevel(rawValue: selectedLevel) {
selectedVerbLevels = [legacy]
}
} }
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] { private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
@@ -86,4 +170,5 @@ final class UserProgress {
} }
return string 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,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() }
}
}
+189 -2
View File
@@ -3,12 +3,15 @@ import SharedModels
import Foundation import Foundation
actor DataLoader { actor DataLoader {
static let courseDataVersion = 7 static let courseDataVersion = 9 // bump: all 19 tense guides + 36 grammar notes enriched to teacher-handout depth
static let courseDataKey = "courseDataVersion" static let courseDataKey = "courseDataVersion"
static let textbookDataVersion = 12 static let textbookDataVersion = 14
static let textbookDataKey = "textbookDataVersion" static let textbookDataKey = "textbookDataVersion"
static let bookDataVersion = 6 // bump: BookChapter.paragraphCount added
static let bookDataKey = "bookDataVersion"
/// Quick check: does the DB need seeding or course data refresh? /// Quick check: does the DB need seeding or course data refresh?
static func needsSeeding(container: ModelContainer) async -> Bool { static func needsSeeding(container: ModelContainer) async -> Bool {
let context = ModelContext(container) let context = ModelContext(container)
@@ -21,6 +24,9 @@ actor DataLoader {
let textbookVersion = UserDefaults.standard.integer(forKey: textbookDataKey) let textbookVersion = UserDefaults.standard.integer(forKey: textbookDataKey)
if textbookVersion < textbookDataVersion { return true } if textbookVersion < textbookDataVersion { return true }
let bookVersion = UserDefaults.standard.integer(forKey: bookDataKey)
if bookVersion < bookDataVersion { return true }
return false return false
} }
@@ -146,6 +152,43 @@ actor DataLoader {
if seedTextbookData(context: context) { if seedTextbookData(context: context) {
UserDefaults.standard.set(textbookDataVersion, forKey: textbookDataKey) 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 /// Re-seed textbook data if the version has changed OR if the rows are
@@ -523,6 +566,150 @@ actor DataLoader {
return true 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"] ?? ""
)
}
}
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
}
/// 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) { private static func seedTextbookVocabDecks(context: ModelContext, courseName: String) {
let url = Bundle.main.url(forResource: "textbook_vocab", withExtension: "json") let url = Bundle.main.url(forResource: "textbook_vocab", withExtension: "json")
?? Bundle.main.bundleURL.appendingPathComponent("textbook_vocab.json") ?? Bundle.main.bundleURL.appendingPathComponent("textbook_vocab.json")
@@ -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
}
@@ -4,14 +4,23 @@ import SwiftData
struct PracticeSettings: Sendable { struct PracticeSettings: Sendable {
let selectedLevel: String let selectedLevel: String
let selectedLevels: Set<String>
let enabledTenses: Set<String> let enabledTenses: Set<String>
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
let showVosotros: Bool let showVosotros: Bool
let showReflexiveVerbsOnly: Bool
let reflexiveBaseInfinitives: Set<String>
init(progress: UserProgress?) { init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
let resolved = progress?.enabledTenseIDs ?? [] let resolvedTenses = progress?.enabledTenseIDs ?? []
let resolvedLevels = progress?.selectedVerbLevels ?? []
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue 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.showVosotros = progress?.showVosotros ?? true
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
} }
var selectionTenseIDs: [String] { var selectionTenseIDs: [String] {
@@ -36,16 +45,25 @@ struct FullTablePrompt {
struct PracticeSessionService { struct PracticeSessionService {
let localContext: ModelContext let localContext: ModelContext
let cloudContext: ModelContext let cloudContext: ModelContext
let reflexiveBaseInfinitives: Set<String>
private let referenceStore: ReferenceStore private let referenceStore: ReferenceStore
init(localContext: ModelContext, cloudContext: ModelContext) { init(
localContext: ModelContext,
cloudContext: ModelContext,
reflexiveBaseInfinitives: Set<String> = []
) {
self.localContext = localContext self.localContext = localContext
self.cloudContext = cloudContext self.cloudContext = cloudContext
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
self.referenceStore = ReferenceStore(context: localContext) self.referenceStore = ReferenceStore(context: localContext)
} }
func settings() -> PracticeSettings { 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? { func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
@@ -77,29 +95,114 @@ struct PracticeSessionService {
return nil 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 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 } guard !verbs.isEmpty else { return nil }
for _ in 0..<40 { let candidateTenseIds = settings.selectionTenseIDs
guard let verb = verbs.randomElement(), guard !candidateTenseIds.isEmpty else { return nil }
let tenseId = settings.selectionTenseIDs.randomElement(),
let tenseInfo = TenseInfo.find(tenseId) else { continue }
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId) let tenseChoices = tenseChoicesAvoidingRepeat(candidateTenseIds, previous: previousTenseId)
if forms.isEmpty { continue } let verbChoices = verbChoicesSwitchingFamily(verbs, previousEnding: previousEnding)
let isConstrained = tenseChoices.count != candidateTenseIds.count
|| verbChoices.count != verbs.count
// Full Table practice is for regular patterns only skip combos // Best-effort: random-sample the constrained pool first so consecutive
// where any form in this (verb, tense) is irregular. // prompts vary the tense and the -ar/-er-ir family.
if forms.contains(where: { $0.regularity != "regular" }) { continue } if isConstrained,
let prompt = sampleFullTablePrompt(verbs: verbChoices, tenseIds: tenseChoices) {
return prompt
}
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms) // 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 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] { func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
ReviewStore.recordReview( ReviewStore.recordReview(
verbId: verbId, verbId: verbId,
@@ -136,6 +239,27 @@ struct PracticeSessionService {
return buildCardLoad(verb: verb, form: form) 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 { private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
let spans = referenceStore.fetchSpans( let spans = referenceStore.fetchSpans(
verbId: form.verbId, verbId: form.verbId,
@@ -157,7 +281,13 @@ struct PracticeSessionService {
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? { private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
let settings = settings() 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() let now = Date()
var descriptor = FetchDescriptor<ReviewCard>( var descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.dueDate <= now }, predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
@@ -184,7 +314,13 @@ struct PracticeSessionService {
private func pickWeakForm() -> VerbForm? { private func pickWeakForm() -> VerbForm? {
let settings = settings() 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>( let descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 }, predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
@@ -206,7 +342,15 @@ struct PracticeSessionService {
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? { private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
let settings = settings() 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> let typeRange: ClosedRange<Int>
switch filter { switch filter {
@@ -243,7 +387,13 @@ struct PracticeSessionService {
private func pickCommonTenseForm() -> VerbForm? { private func pickCommonTenseForm() -> VerbForm? {
let settings = settings() let settings = settings()
let coreTenseIDs = TenseID.coreTenseIDs 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 } guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
@@ -256,7 +406,13 @@ struct PracticeSessionService {
private func pickRandomForm() -> VerbForm? { private func pickRandomForm() -> VerbForm? {
let settings = settings() 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 } guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
@@ -27,6 +27,50 @@ struct ReferenceStore {
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id)) 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? { func fetchVerb(id: Int) -> Verb? {
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id }) let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
return (try? context.fetch(descriptor))?.first return (try? context.fetch(descriptor))?.first
@@ -50,6 +94,19 @@ struct ReferenceStore {
return (try? context.fetch(descriptor)) ?? [] 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? { func fetchForm(verbId: Int, tenseId: String, personIndex: Int) -> VerbForm? {
let descriptor = FetchDescriptor<VerbForm>( let descriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { form in 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 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 @discardableResult
static func updateProgress( static func recordActivity(context: ModelContext, date: Date = Date()) -> UserProgress {
reviewIncrement: Int,
correctIncrement: Int,
context: ModelContext,
date: Date = Date()
) -> UserProgress {
let progress = fetchOrCreateUserProgress(context: context) let progress = fetchOrCreateUserProgress(context: context)
let todayString = DailyLog.dateString(from: date) let todayString = DailyLog.dateString(from: date)
@@ -97,9 +97,25 @@ struct ReviewStore {
progress.todayCount = 0 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.todayCount += reviewIncrement
progress.totalReviewed += reviewIncrement progress.totalReviewed += reviewIncrement
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
let log = fetchOrCreateDailyLog(dateString: todayString, context: context) let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
log.reviewCount += reviewIncrement log.reviewCount += reviewIncrement
@@ -10,6 +10,7 @@ enum StartupCoordinator {
await DataLoader.seedIfNeeded(container: localContainer) await DataLoader.seedIfNeeded(container: localContainer)
await DataLoader.refreshCourseDataIfNeeded(container: localContainer) await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer) await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
await DataLoader.refreshBooksDataIfNeeded(container: localContainer)
} }
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup. /// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
@@ -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 = [] currentSpans = []
hasCards = true hasCards = true
isLoading = 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 { guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else {
clearCurrentCard() clearCurrentCard()
hasCards = false hasCards = false
@@ -585,6 +585,7 @@ struct CourseQuizView: View {
) )
cloudModelContext.insert(result) cloudModelContext.insert(result)
try? cloudModelContext.save() try? cloudModelContext.save()
ReviewStore.recordActivity(context: cloudModelContext)
} }
} }
+48 -1
View File
@@ -8,11 +8,16 @@ struct CourseView: View {
@Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter] @Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter]
@AppStorage("selectedCourse") private var selectedCourse: String? @AppStorage("selectedCourse") private var selectedCourse: String?
@State private var testResults: [TestResult] = [] @State private var testResults: [TestResult] = []
@State private var extraStudyCounts: [Int: Int] = [:]
private var textbookCourses: [String] { private var textbookCourses: [String] {
Array(Set(textbookChapters.map(\.courseName))).sorted() Array(Set(textbookChapters.map(\.courseName))).sorted()
} }
private var activeCourseIsTextbook: Bool {
textbookCourses.contains(activeCourse)
}
private var cloudModelContext: ModelContext { cloudModelContextProvider() } private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var courseNames: [String] { private var courseNames: [String] {
@@ -169,6 +174,28 @@ struct CourseView: View {
} }
} }
} }
// Extra Study row only when there are marks for this week
if !activeCourseIsTextbook, let markCount = extraStudyCounts[week], markCount > 0 {
NavigationLink(value: ExtraStudyDestination(courseName: activeCourse, weekNumber: week)) {
HStack(spacing: 12) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.yellow)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Extra Study")
.font(.subheadline.weight(.semibold))
Text("\(markCount) marked card\(markCount == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
} header: { } header: {
Text("Week \(week)") Text("Week \(week)")
} }
@@ -176,10 +203,19 @@ struct CourseView: View {
} }
} }
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse)) .navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
.onAppear(perform: loadTestResults) .onAppear {
loadTestResults()
loadExtraStudyCounts()
}
.onChange(of: activeCourse) { _, _ in
loadExtraStudyCounts()
}
.navigationDestination(for: CourseDeck.self) { deck in .navigationDestination(for: CourseDeck.self) { deck in
DeckStudyView(deck: deck) DeckStudyView(deck: deck)
} }
.navigationDestination(for: ExtraStudyDestination.self) { dest in
ExtraStudyView(courseName: dest.courseName, weekNumber: dest.weekNumber)
}
.navigationDestination(for: WeekTestDestination.self) { dest in .navigationDestination(for: WeekTestDestination.self) { dest in
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber) WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
} }
@@ -210,6 +246,12 @@ struct CourseView: View {
private func loadTestResults() { private func loadTestResults() {
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? [] testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
} }
private func loadExtraStudyCounts() {
guard !activeCourse.isEmpty else { return }
extraStudyCounts = ExtraStudyStore(context: cloudModelContext)
.countsByWeek(courseName: activeCourse)
}
} }
// MARK: - Navigation // MARK: - Navigation
@@ -228,6 +270,11 @@ struct TextbookDestination: Hashable {
let courseName: String let courseName: String
} }
struct ExtraStudyDestination: Hashable {
let courseName: String
let weekNumber: Int
}
// MARK: - Deck Row // MARK: - Deck Row
private struct DeckRowView: View { private struct DeckRowView: View {
@@ -5,6 +5,9 @@ import SwiftData
struct DeckStudyView: View { struct DeckStudyView: View {
let deck: CourseDeck let deck: CourseDeck
@Environment(\.modelContext) private var modelContext @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 isStudying = false
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
@State private var deckCards: [VocabCard] = [] @State private var deckCards: [VocabCard] = []
@@ -14,6 +17,18 @@ struct DeckStudyView: View {
deck.title.localizedCaseInsensitiveContains("stem changing") deck.title.localizedCaseInsensitiveContains("stem changing")
} }
private var isTextbookDeck: Bool {
textbookChapters.contains { $0.courseName == deck.courseName }
}
private var markContext: ExtraStudyMarkContext? {
guard !isTextbookDeck else { return nil }
return ExtraStudyMarkContext(
courseName: deck.courseName,
weekNumber: deck.weekNumber
)
}
var body: some View { var body: some View {
cardListView cardListView
.navigationTitle(deck.title) .navigationTitle(deck.title)
@@ -24,8 +39,12 @@ struct DeckStudyView: View {
VocabFlashcardView( VocabFlashcardView(
cards: deckCards.shuffled(), cards: deckCards.shuffled(),
speechService: speechService, speechService: speechService,
onDone: { isStudying = false }, onDone: {
deckTitle: deck.title ReviewStore.recordActivity(context: cloudModelContext)
isStudying = false
},
deckTitle: deck.title,
markContext: markContext
) )
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
@@ -0,0 +1,75 @@
import SwiftUI
import SharedModels
import SwiftData
/// Study session containing only cards the user has marked for extra study,
/// scoped to a specific (courseName, weekNumber). Resolves marks by re-hashing
/// each VocabCard via `CourseCardStore.reviewKey` so the matching is robust to
/// duplicate (deckId, front, back) tuples that differ in examples.
struct ExtraStudyView: View {
let courseName: String
let weekNumber: Int
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var cards: [VocabCard] = []
@State private var speechService = SpeechService()
@State private var loaded = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
Group {
if !loaded {
ProgressView()
.controlSize(.large)
} else if cards.isEmpty {
ContentUnavailableView(
"No Marked Cards",
systemImage: "star",
description: Text("Tap the star on a card during study to add it here.")
)
} else {
VocabFlashcardView(
cards: cards.shuffled(),
speechService: speechService,
onDone: {
ReviewStore.recordActivity(context: cloudContext)
dismiss()
},
deckTitle: "Extra Study",
markContext: ExtraStudyMarkContext(
courseName: courseName,
weekNumber: weekNumber
)
)
}
}
.navigationTitle("Extra Study · Week \(weekNumber)")
.navigationBarTitleDisplayMode(.inline)
.task { load() }
}
private func load() {
guard !loaded else { return }
let marks = ExtraStudyStore(context: cloudContext)
.fetch(courseName: courseName, weekNumber: weekNumber)
let markIds = Set(marks.map(\.id))
let deckIds = Set(marks.map(\.deckId))
var collected: [VocabCard] = []
for deckId in deckIds {
let descriptor = FetchDescriptor<VocabCard>(
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
)
let deckCards = (try? localContext.fetch(descriptor)) ?? []
collected.append(contentsOf: deckCards.filter {
markIds.contains(CourseCardStore.reviewKey(for: $0))
})
}
cards = collected
loaded = true
}
}
@@ -249,6 +249,7 @@ struct TextbookExerciseView: View {
} }
grades = newGrades grades = newGrades
isChecked = true isChecked = true
ReviewStore.recordActivity(context: cloudModelContext)
saveAttempt(states: states, exerciseId: b.exerciseId ?? "") saveAttempt(states: states, exerciseId: b.exerciseId ?? "")
} }
@@ -9,12 +9,16 @@ struct VocabFlashcardView: View {
/// Optional deck context when present and the title indicates a stem- /// Optional deck context when present and the title indicates a stem-
/// changing deck, each card gets an inline conjugation toggle. /// changing deck, each card gets an inline conjugation toggle.
var deckTitle: String? = nil var deckTitle: String? = nil
/// When set, a star button appears next to the speaker on reveal so the
/// user can mark the card for extra study.
var markContext: ExtraStudyMarkContext? = nil
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var currentIndex = 0 @State private var currentIndex = 0
@State private var isRevealed = false @State private var isRevealed = false
@State private var sessionCorrect = 0 @State private var sessionCorrect = 0
@State private var showConjugation = false @State private var showConjugation = false
@State private var markedIds: Set<String> = []
private var isStemChangingDeck: Bool { private var isStemChangingDeck: Bool {
(deckTitle ?? "").localizedCaseInsensitiveContains("stem changing") (deckTitle ?? "").localizedCaseInsensitiveContains("stem changing")
@@ -61,14 +65,29 @@ struct VocabFlashcardView: View {
.font(.title.weight(.medium)) .font(.title.weight(.medium))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button { HStack(spacing: 12) {
speechService.speak(card.front) Button {
} label: { speechService.speak(card.front)
Image(systemName: "speaker.wave.2.fill") } label: {
.font(.title3) Image(systemName: "speaker.wave.2.fill")
.padding(12) .font(.title3)
.padding(12)
}
.glassEffect(in: .circle)
if markContext != nil {
Button {
toggleMark()
} label: {
Image(systemName: currentIsMarked ? "star.fill" : "star")
.font(.title3)
.padding(12)
.foregroundStyle(currentIsMarked ? .yellow : .secondary)
}
.glassEffect(in: .circle)
.accessibilityLabel(currentIsMarked ? "Unmark for extra study" : "Mark for extra study")
}
} }
.glassEffect(in: .circle)
if isStemChangingDeck { if isStemChangingDeck {
Button { Button {
@@ -194,6 +213,34 @@ struct VocabFlashcardView: View {
} }
.animation(.smooth, value: isRevealed) .animation(.smooth, value: isRevealed)
.animation(.smooth, value: currentIndex) .animation(.smooth, value: currentIndex)
.task { loadMarks() }
}
private var currentIsMarked: Bool {
guard let card = currentCard else { return false }
return markedIds.contains(CourseCardStore.reviewKey(for: card))
}
private func loadMarks() {
guard let ctx = markContext else { return }
markedIds = ExtraStudyStore(context: cloudModelContext)
.fetchIds(courseName: ctx.courseName, weekNumber: ctx.weekNumber)
}
private func toggleMark() {
guard let card = currentCard, let ctx = markContext else { return }
let store = ExtraStudyStore(context: cloudModelContext)
let isNowMarked = store.toggle(
card: card,
courseName: ctx.courseName,
weekNumber: ctx.weekNumber
)
let key = CourseCardStore.reviewKey(for: card)
if isNowMarked {
markedIds.insert(key)
} else {
markedIds.remove(key)
}
} }
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View { private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
@@ -288,7 +288,10 @@ struct DashboardView: View {
} }
private func loadData() { 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>( let dailyDescriptor = FetchDescriptor<DailyLog>(
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)] sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
) )
@@ -1,9 +1,12 @@
import SwiftUI import SwiftUI
import SwiftData
struct GrammarExerciseView: View { struct GrammarExerciseView: View {
let noteId: String let noteId: String
let noteTitle: String let noteTitle: String
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var exercises: [GrammarExercise] = [] @State private var exercises: [GrammarExercise] = []
@State private var currentIndex = 0 @State private var currentIndex = 0
@@ -96,6 +99,7 @@ struct GrammarExerciseView: View {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
} else { } else {
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isFinished = true } withAnimation { isFinished = true }
} }
} label: { } label: {
@@ -1,4 +1,6 @@
import SwiftUI import SwiftUI
import SwiftData
import SharedModels
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView). /// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
struct GrammarNotesView: View { struct GrammarNotesView: View {
@@ -67,6 +69,24 @@ private struct GrammarNoteRow: View {
struct GrammarNoteDetailView: View { struct GrammarNoteDetailView: View {
let note: GrammarNote 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 { var body: some View {
ScrollView { ScrollView {
@@ -83,6 +103,12 @@ struct GrammarNoteDetailView: View {
.background(.fill.tertiary, in: Capsule()) .background(.fill.tertiary, in: Capsule())
} }
if !relatedTenses.isEmpty {
relatedTensesSection
}
videoSection
Divider() Divider()
// Parsed body // Parsed body
@@ -107,6 +133,49 @@ struct GrammarNoteDetailView: View {
} }
.navigationTitle(note.title) .navigationTitle(note.title)
.navigationBarTitleDisplayMode(.inline) .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())
}
} }
} }
+86 -5
View File
@@ -41,15 +41,20 @@ struct GuideView: View {
.navigationTitle("Guide") .navigationTitle("Guide")
.task { loadGuides() } .task { loadGuides() }
.onAppear(perform: loadGuides) .onAppear(perform: loadGuides)
.onChange(of: selectedTab) { _, _ in .onChange(of: selectedTab) { _, newTab in
selectedGuide = nil // Only clear the *other* tab's selection so programmatic
selectedNote = nil // 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: { } detail: {
if let guide = selectedGuide { if let guide = selectedGuide {
GuideDetailView(guide: guide) GuideDetailView(guide: guide, onJumpToNote: jumpToNote)
} else if let note = selectedNote { } else if let note = selectedNote {
GrammarNoteDetailView(note: note) GrammarNoteDetailView(note: note, onJumpToTense: jumpToTense)
} else { } else {
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more.")) 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) GrammarNotesListView(selectedNote: $selectedNote)
} }
private func jumpToNote(_ note: GrammarNote) {
selectedTab = .grammar
selectedNote = note
}
private func jumpToTense(_ guide: TenseGuide) {
selectedTab = .tenses
selectedGuide = guide
}
private func loadGuides() { private func loadGuides() {
// Hit the shared local container directly, bypassing @Environment. // Hit the shared local container directly, bypassing @Environment.
guard let container = SharedStore.localContainer else { guard let container = SharedStore.localContainer else {
@@ -127,11 +142,23 @@ private struct TenseRowView: View {
struct GuideDetailView: View { struct GuideDetailView: View {
let guide: TenseGuide 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? { private var tenseInfo: TenseInfo? {
TenseInfo.find(guide.tenseId) TenseInfo.find(guide.tenseId)
} }
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
videoStore.video(forTenseId: guide.tenseId)
}
private var endingTable: TenseEndingTable? { private var endingTable: TenseEndingTable? {
TenseEndingTable.find(guide.tenseId) TenseEndingTable.find(guide.tenseId)
} }
@@ -146,6 +173,14 @@ struct GuideDetailView: View {
// Header // Header
headerSection headerSection
// Related grammar notes cross-links into the Grammar tab
if !relatedNotes.isEmpty {
relatedNotesSection
}
// Video section (Issue #21)
videoSection
// Conjugation ending table // Conjugation ending table
if let table = endingTable { if let table = endingTable {
conjugationTableSection(table) conjugationTableSection(table)
@@ -180,6 +215,51 @@ struct GuideDetailView: View {
.navigationBarTitleDisplayMode(.inline) .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 // MARK: - Header
private var headerSection: some View { private var headerSection: some View {
@@ -571,4 +651,5 @@ struct GuideExample: Identifiable {
#Preview { #Preview {
GuideView() GuideView()
.modelContainer(for: TenseGuide.self, inMemory: true) .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() { private func completeOnboarding() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress.selectedVerbLevel = selectedLevel progress.selectedVerbLevels = [selectedLevel]
if progress.enabledTenseIDs.isEmpty { if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses() progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
} }
@@ -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)
}
}
}
@@ -119,6 +119,7 @@ struct ChatView: View {
messages = conversation.decodedMessages messages = conversation.decodedMessages
inputText = "" inputText = ""
try? cloudContext.save() try? cloudContext.save()
ReviewStore.recordActivity(context: cloudContext)
Task { Task {
do { do {
@@ -98,6 +98,7 @@ struct ClozeView: View {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
} else { } else {
ReviewStore.recordActivity(context: cloudContext)
withAnimation { isFinished = true } withAnimation { isFinished = true }
} }
} label: { } label: {
@@ -20,6 +20,7 @@ struct FullTableView: View {
@State private var useHandwriting = false @State private var useHandwriting = false
@State private var sessionCount = 0 @State private var sessionCount = 0
@State private var sessionCorrect = 0 @State private var sessionCorrect = 0
@State private var noEligibleVerbs = false
// Handwriting state per field // Handwriting state per field
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6) @State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
@@ -53,35 +54,39 @@ struct FullTableView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 32) { if noEligibleVerbs {
// Header emptyPoolError
if let verb = currentVerb, let tense = currentTense { } else {
headerSection(verb: verb, tense: tense) VStack(spacing: 32) {
} // Header
if let verb = currentVerb, let tense = currentTense {
// Input mode toggle headerSection(verb: verb, tense: tense)
HStack { }
Picker("Input", selection: $useHandwriting) {
Label("Keyboard", systemImage: "keyboard").tag(false) // Input mode toggle
Label("Pencil", systemImage: "pencil.and.outline").tag(true) 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") .navigationTitle("Full Table")
.navigationBarTitleDisplayMode(.inline) .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 // MARK: - Header
private func headerSection(verb: Verb, tense: TenseInfo) -> some View { private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
@@ -243,15 +264,27 @@ struct FullTableView: View {
results = Array(repeating: nil, count: 6) results = Array(repeating: nil, count: 6)
correctForms = [] correctForms = []
drawings = Array(repeating: PKDrawing(), count: 6) drawings = Array(repeating: PKDrawing(), count: 6)
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext) let service = PracticeSessionService(
guard let prompt = service.randomFullTablePrompt() else { 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 currentVerb = nil
currentTense = nil currentTense = nil
userAnswers = Array(repeating: "", count: 6) userAnswers = Array(repeating: "", count: 6)
focusedField = nil focusedField = nil
noEligibleVerbs = true
return return
} }
noEligibleVerbs = false
currentVerb = prompt.verb currentVerb = prompt.verb
currentTense = prompt.tenseInfo currentTense = prompt.tenseInfo
correctForms = prompt.forms correctForms = prompt.forms
@@ -312,7 +345,11 @@ struct FullTableView: View {
if allCorrect { sessionCorrect += 1 } if allCorrect { sessionCorrect += 1 }
if let verb = currentVerb, let tense = currentTense { 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) }) let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults) _ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
} }
@@ -4,6 +4,8 @@ import SwiftData
struct ListeningView: View { struct ListeningView: View {
@Environment(\.modelContext) private var localContext @Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var pronunciation = PronunciationService() @State private var pronunciation = PronunciationService()
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
@@ -122,6 +124,7 @@ struct ListeningView: View {
Button { Button {
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput) let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
if result.score >= 0.7 { correctCount += 1 } if result.score >= 0.7 { correctCount += 1 }
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isRevealed = true } withAnimation { isRevealed = true }
} label: { } label: {
Text("Check") Text("Check")
@@ -164,6 +167,7 @@ struct ListeningView: View {
score = result.score score = result.score
wordMatches = result.matches wordMatches = result.matches
if result.score >= 0.7 { correctCount += 1 } if result.score >= 0.7 { correctCount += 1 }
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isRevealed = true } withAnimation { isRevealed = true }
} else { } else {
pronunciation.startRecording() pronunciation.startRecording()
@@ -1,9 +1,16 @@
import SwiftUI import SwiftUI
import SwiftData
import SharedModels import SharedModels
struct LyricsReaderView: View { struct LyricsReaderView: View {
let song: SavedSong 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 { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
@@ -15,6 +22,10 @@ struct LyricsReaderView: View {
} }
.navigationTitle(song.title) .navigationTitle(song.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedWord) { word in
LyricsWordDetailSheet(word: word)
.presentationDetents([.height(260)])
}
} }
// MARK: - Header // MARK: - Header
@@ -56,15 +67,6 @@ struct LyricsReaderView: View {
let spanishLines = song.lyricsES.components(separatedBy: "\n") let spanishLines = song.lyricsES.components(separatedBy: "\n")
let englishLines = song.lyricsEN.components(separatedBy: "\n") let englishLines = song.lyricsEN.components(separatedBy: "\n")
let lineCount = max(spanishLines.count, englishLines.count) 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) { return VStack(alignment: .leading, spacing: 0) {
ForEach(0..<lineCount, id: \.self) { index in ForEach(0..<lineCount, id: \.self) { index in
@@ -78,8 +80,7 @@ struct LyricsReaderView: View {
} else { } else {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
if !es.isEmpty { if !es.isEmpty {
Text(es) spanishLine(es)
.font(.body.weight(.medium))
} }
if !en.isEmpty { if !en.isEmpty {
Text(en) Text(en)
@@ -94,4 +95,184 @@ struct LyricsReaderView: View {
.padding() .padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) .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
}
} }
+213 -160
View File
@@ -21,6 +21,19 @@ struct PracticeView: View {
practiceHomeView 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") .navigationTitle("Practice")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadProgress) .onAppear(perform: loadProgress)
@@ -74,12 +87,10 @@ struct PracticeView: View {
.padding(.top, 8) .padding(.top, 8)
} }
// Mode selection // === Section: Conjugation ===
VStack(spacing: 12) { sectionHeader("Conjugation")
Text("Choose a Mode")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 12) {
ForEach(PracticeMode.allCases) { mode in ForEach(PracticeMode.allCases) { mode in
ModeButton(mode: mode) { ModeButton(mode: mode) {
viewModel.practiceMode = mode viewModel.practiceMode = mode
@@ -98,6 +109,15 @@ struct PracticeView: View {
} }
.padding(.horizontal) .padding(.horizontal)
conjugationFocusButtons
// === Section: Vocabulary ===
sectionHeader("Vocabulary")
vocabSection
// === Section: Reading ===
sectionHeader("Reading")
// Lyrics // Lyrics
NavigationLink { NavigationLink {
LyricsLibraryView() LyricsLibraryView()
@@ -253,166 +273,33 @@ struct PracticeView: View {
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal) .padding(.horizontal)
// Quick Actions // Books
VStack(spacing: 12) { NavigationLink(value: BooksRoute.library) {
Text("Quick Actions") HStack(spacing: 14) {
.font(.headline) Image(systemName: "books.vertical.fill")
.frame(maxWidth: .infinity, alignment: .leading) .font(.title3)
.frame(width: 36)
.foregroundStyle(.indigo)
// Vocab review VStack(alignment: .leading, spacing: 2) {
NavigationLink { Text("Books")
VocabReviewView() .font(.subheadline.weight(.semibold))
} label: { Text("Read full-length books with tap-to-define")
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) .font(.caption)
.foregroundStyle(.tertiary) .foregroundStyle(.secondary)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12) Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
} }
.tint(.primary) .padding(.horizontal, 16)
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .padding(.vertical, 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))
} }
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal) .padding(.horizontal)
// Session stats summary // Session stats summary
@@ -442,6 +329,172 @@ 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",
subtitle: "Re-review 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)
}
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 // MARK: - Practice Session View
@ViewBuilder @ViewBuilder
@@ -4,6 +4,8 @@ import SwiftData
struct SentenceBuilderView: View { struct SentenceBuilderView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var currentCard: VocabCard? @State private var currentCard: VocabCard?
@State private var exampleIndex: Int = 0 @State private var exampleIndex: Int = 0
@@ -316,6 +318,7 @@ struct SentenceBuilderView: View {
if isCorrect { if isCorrect {
sessionCorrect += 1 sessionCorrect += 1
} }
ReviewStore.recordActivity(context: cloudModelContext)
} }
private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? { private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
@@ -1,9 +1,13 @@
import SwiftUI import SwiftUI
import SwiftData
import SharedModels import SharedModels
struct StoryQuizView: View { struct StoryQuizView: View {
let story: Story let story: Story
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var currentIndex = 0 @State private var currentIndex = 0
@State private var selectedOption: Int? @State private var selectedOption: Int?
@State private var correctCount = 0 @State private var correctCount = 0
@@ -85,6 +89,7 @@ struct StoryQuizView: View {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
} else { } else {
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isFinished = true } withAnimation { isFinished = true }
} }
} label: { } label: {
@@ -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,296 @@
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 {
@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("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 {
return "\(learned) verb\(learned == 1 ? "" : "s") learned"
}
return "No verbs are due right now. Study Again to review anyway."
}
// MARK: - Logic
private func loadIfNeeded() {
guard session == nil else { return }
let verbs = VocabVerbPool.sessionVerbs(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
if let graduation {
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) { private func rate(quality: ReviewQuality) {
guard let card = dueCards[safe: currentIndex] else { return } guard let card = dueCards[safe: currentIndex] else { return }
ReviewStore.recordActivity(context: cloudContext)
let store = CourseReviewStore(context: cloudContext) let store = CourseReviewStore(context: cloudContext)
let result = SRSEngine.review( let result = SRSEngine.review(
quality: quality, 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 { struct FeatureReferenceView: View {
var body: some View { var body: some View {
List { List {
Section("Verb Conjugation Practice") { // MARK: Conjugation
Section("Practice — Conjugation") {
featureRow( featureRow(
icon: "rectangle.stack", color: .blue, icon: "rectangle.stack", color: .blue,
title: "Flashcard / Typing / MC / Handwriting / Sentence Builder", title: "Flashcard / Typing / MC / Handwriting / Sentence Builder",
details: [ 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 Level setting",
"Filtered by your Enabled Tenses", "Filtered by your Enabled Tenses",
"Respects Include Vosotros setting", "Respects the Include Vosotros setting",
"Due cards (SRS) shown first, then random", "Due cards (SRS) shown first, then random",
] ]
) )
@@ -21,13 +23,11 @@ struct FeatureReferenceView: View {
title: "Full Table", title: "Full Table",
details: [ details: [
"Shows all 6 person forms for one verb + tense", "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", "Random tense from your Enabled Tenses",
] ]
) )
}
Section("Quick Actions") {
featureRow( featureRow(
icon: "star.fill", color: .orange, icon: "star.fill", color: .orange,
title: "Common Tenses", title: "Common Tenses",
@@ -57,20 +57,86 @@ struct FeatureReferenceView: View {
"Filtered by your Level and Enabled Tenses", "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( featureRow(
icon: "rectangle.stack.fill", color: .teal, icon: "rectangle.stack.fill", color: .teal,
title: "Vocab Review", title: "Vocab Review",
details: [ 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", "Cards become due after you study them in Course quizzes",
"Rate Again/Hard/Good/Easy to schedule next review", "Rate Again/Hard/Good/Easy to schedule the next review",
"Uses all course vocabulary, not filtered by level", "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( featureRow(
icon: "bubble.left.and.bubble.right.fill", color: .green, icon: "bubble.left.and.bubble.right.fill", color: .green,
title: "Conversation", title: "Conversation",
@@ -79,8 +145,7 @@ struct FeatureReferenceView: View {
"10 scenario types (restaurant, directions, etc.)", "10 scenario types (restaurant, directions, etc.)",
"AI adapts vocabulary to your Level setting", "AI adapts vocabulary to your Level setting",
"Corrections provided inline when you make mistakes", "Corrections provided inline when you make mistakes",
"Conversations saved to iCloud for revisiting", "Requires an Apple Intelligence-capable device",
"Requires Apple Intelligence-capable device",
] ]
) )
@@ -91,8 +156,6 @@ struct FeatureReferenceView: View {
"Listen & Type: hear a sentence, type what you heard", "Listen & Type: hear a sentence, type what you heard",
"Pronunciation: read a sentence aloud, get scored on accuracy", "Pronunciation: read a sentence aloud, get scored on accuracy",
"Sentences pulled from course vocabulary examples", "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", title: "Cloze Practice",
details: [ details: [
"Fill in the missing word in a Spanish sentence", "Fill in the missing word in a Spanish sentence",
"Sentences from course vocabulary examples",
"4 multiple-choice options (1 correct + 3 distractors)", "4 multiple-choice options (1 correct + 3 distractors)",
"Distractors are other vocabulary words from same pool", "Sentences from course vocabulary examples",
"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",
] ]
) )
} }
// MARK: Guide
Section("Guide") { Section("Guide") {
featureRow( featureRow(
icon: "book", color: .brown, icon: "book", color: .brown,
title: "Tense Guides", title: "Tense Guides",
details: [ details: [
"Detailed explanation of each of the 20 verb tenses", "In-depth guide to each of the 20 verb tenses",
"Conjugation ending tables for -ar, -er, -ir verbs", "Conjugation ending tables, common irregulars, mnemonics",
"Usage patterns with example sentences", "Usage patterns, pitfalls, and contrast with neighbouring tenses",
"Essential tenses marked with orange badge", "Essential tenses marked with an orange badge",
] ]
) )
@@ -152,14 +188,24 @@ struct FeatureReferenceView: View {
icon: "doc.text", color: .brown, icon: "doc.text", color: .brown,
title: "Grammar Notes", title: "Grammar Notes",
details: [ details: [
"23 grammar topics (ser vs estar, por vs para, etc.)", "36 grammar topics (ser vs estar, por vs para, WEIRDO, etc.)",
"Interactive exercises available for 5 topics", "Each with a mnemonic, contrast examples, and common pitfalls",
"Tap 'Practice This' on notes that have exercises", "Interactive exercises available on selected topics",
"Content grouped by category with card-based layout", ]
)
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") { Section("Course") {
featureRow( featureRow(
icon: "list.clipboard", color: .orange, icon: "list.clipboard", color: .orange,
@@ -167,11 +213,21 @@ struct FeatureReferenceView: View {
details: [ details: [
"Vocabulary from specific course weeks", "Vocabulary from specific course weeks",
"Multiple quiz types: MC, typing, handwriting, cloze", "Multiple quiz types: MC, typing, handwriting, cloze",
"Focus Area mode for missed words",
"Not filtered by Level (uses course structure)", "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( featureRow(
icon: "checkmark.seal", color: .orange, icon: "checkmark.seal", color: .orange,
title: "Checkpoint Exams", title: "Checkpoint Exams",
@@ -183,23 +239,27 @@ struct FeatureReferenceView: View {
) )
} }
// MARK: Dashboard
Section("Dashboard") { Section("Dashboard") {
featureRow( featureRow(
icon: "clock.fill", color: .mint, icon: "clock.fill", color: .mint,
title: "Study Time", title: "Study Time",
details: [ details: [
"Tracks time the app is in the foreground", "Tracks time the app is in the foreground",
"Starts when app becomes active, stops on background", "Shows today's time and an all-time total",
"Shows today's time and all-time total",
"7-day bar chart of daily study time", "7-day bar chart of daily study time",
] ]
) )
} }
// MARK: Settings
Section("Settings That Affect Practice") { Section("Settings That Affect Practice") {
settingRow(name: "Level", affects: "Verb practice, Full Table, Quick Actions, Stories, Conversation") 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: "Verb practice, Full Table, Irregularity Drills, Stories") settingRow(name: "Enabled Tenses", affects: "Conjugation practice, Full Table, Irregularity Drills, Stories")
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions") 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") settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")
} }
} }
@@ -9,9 +9,15 @@ struct SettingsView: View {
@State private var dailyGoal: Double = 50 @State private var dailyGoal: Double = 50
@State private var showVosotros: Bool = true @State private var showVosotros: Bool = true
@State private var autoFillStem: Bool = false @State private var autoFillStem: Bool = false
@State private var selectedLevel: VerbLevel = .basic
/// Cards per vocab-practice session. 999 = "All" (no cap).
@AppStorage("vocabSessionCardLimit") private var vocabSessionCardLimit: Int = 20
private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999]
private let levels = VerbLevel.allCases private let levels = VerbLevel.allCases
private let irregularCategories: [IrregularSpan.SpanCategory] = [
.spelling, .stemChange, .uniqueIrregular
]
private var cloudModelContext: ModelContext { cloudModelContextProvider() } private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View { var body: some View {
@@ -40,19 +46,38 @@ struct SettingsView: View {
} }
} }
Section("Level") { Section {
Picker("Current Level", selection: $selectedLevel) { Picker("Cards per session", selection: $vocabSessionCardLimit) {
ForEach(levels, id: \.self) { level in ForEach(vocabSessionSizes, id: \.self) { size in
Text(level.displayName).tag(level) Text(size == 999 ? "All" : "\(size)").tag(size)
} }
} }
.onChange(of: selectedLevel) { _, newValue in } header: {
progress?.selectedVerbLevel = newValue Text("Vocab Flashcards")
saveProgress() } footer: {
} Text("How many verbs a Vocab Flashcards session draws. Overdue verbs 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("Levels")
} footer: {
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
}
Section {
ForEach(TenseInfo.all) { tense in ForEach(TenseInfo.all) { tense in
Toggle(tense.english, isOn: Binding( Toggle(tense.english, isOn: Binding(
get: { get: {
@@ -65,6 +90,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") { Section("Stats") {
@@ -79,6 +139,9 @@ struct SettingsView: View {
NavigationLink("How Features Work") { NavigationLink("How Features Work") {
FeatureReferenceView() FeatureReferenceView()
} }
NavigationLink("Downloaded Videos") {
DownloadedVideosView()
}
} }
Section("About") { Section("About") {
@@ -96,7 +159,6 @@ struct SettingsView: View {
dailyGoal = Double(resolved.dailyGoal) dailyGoal = Double(resolved.dailyGoal)
showVosotros = resolved.showVosotros showVosotros = resolved.showVosotros
autoFillStem = resolved.autoFillStem autoFillStem = resolved.autoFillStem
selectedLevel = resolved.selectedVerbLevel
} }
private func saveProgress() { private func saveProgress() {
@@ -4,14 +4,40 @@ import SwiftData
struct VerbDetailView: View { struct VerbDetailView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
let verb: Verb let verb: Verb
@State private var selectedTense: TenseInfo = TenseInfo.all[0] @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] { private var formsForTense: [VerbForm] {
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id) ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
} }
private var reflexiveEntries: [ReflexiveVerb] {
reflexiveStore.entries(for: verb.infinitive)
}
var body: some View { var body: some View {
List { List {
Section { Section {
@@ -25,6 +51,10 @@ struct VerbDetailView: View {
Text("Info") Text("Info")
} }
if !reflexiveEntries.isEmpty {
reflexiveSection
}
Section { Section {
Picker("Tense", selection: $selectedTense) { Picker("Tense", selection: $selectedTense) {
ForEach(TenseInfo.all) { tense in ForEach(TenseInfo.all) { tense in
@@ -66,6 +96,8 @@ struct VerbDetailView: View {
} header: { } header: {
Text("Conjugation") Text("Conjugation")
} }
examplesSection
} }
.navigationTitle(verb.infinitive) .navigationTitle(verb.infinitive)
.toolbar { .toolbar {
@@ -78,6 +110,129 @@ struct VerbDetailView: View {
.tint(.secondary) .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")) 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) .modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
.environment(VerbExampleCache())
.environment(ReflexiveVerbStore())
} }
+92 -15
View File
@@ -22,17 +22,38 @@ enum IrregularityCategory: String, CaseIterable, Identifiable {
struct VerbListView: View { struct VerbListView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var verbs: [Verb] = [] @State private var verbs: [Verb] = []
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:] @State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
@State private var searchText = "" @State private var searchText = ""
@State private var selectedLevel: String? @State private var progress: UserProgress?
@State private var selectedIrregularity: IrregularityCategory? @State private var selectedIrregularity: IrregularityCategory?
@State private var reflexiveOnly: Bool = false
@State private var selectedVerb: Verb? @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] { private var filteredVerbs: [Verb] {
var result = verbs var result = verbs
if let level = selectedLevel { if !allLevelsActive {
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) } let activeRaws = Set(selectedLevels.map(\.rawValue))
result = result.filter { verb in
activeRaws.contains { VerbLevelGroup.matches(verb.level, selectedLevel: $0) }
}
} }
if let category = selectedIrregularity { if let category = selectedIrregularity {
result = result.filter { verb in result = result.filter { verb in
@@ -40,6 +61,9 @@ struct VerbListView: View {
return category == .anyIrregular ? !cats.isEmpty : cats.contains(category) return category == .anyIrregular ? !cats.isEmpty : cats.contains(category)
} }
} }
if reflexiveOnly {
result = result.filter { reflexiveStore.isReflexive(baseInfinitive: $0.infinitive) }
}
if !searchText.isEmpty { if !searchText.isEmpty {
let query = searchText.lowercased() let query = searchText.lowercased()
result = result.filter { result = result.filter {
@@ -50,7 +74,7 @@ struct VerbListView: View {
return result return result
} }
private let levels = ["basic", "elementary", "intermediate", "advanced", "expert"] private let levels: [VerbLevel] = VerbLevel.allCases
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
@@ -71,15 +95,15 @@ struct VerbListView: View {
Menu { Menu {
Section("Level") { Section("Level") {
Button { Button {
selectedLevel = nil setAllLevels(enabled: true)
} label: { } label: {
Label("All Levels", systemImage: selectedLevel == nil ? "checkmark" : "") Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "circle")
} }
ForEach(levels, id: \.self) { level in ForEach(levels, id: \.self) { level in
Button { Button {
selectedLevel = level toggleLevel(level)
} label: { } label: {
Label(level.capitalized, systemImage: selectedLevel == level ? "checkmark" : "") Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "circle")
} }
} }
} }
@@ -88,7 +112,7 @@ struct VerbListView: View {
Button { Button {
selectedIrregularity = nil selectedIrregularity = nil
} label: { } label: {
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "") Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "circle")
} }
ForEach(IrregularityCategory.allCases) { category in ForEach(IrregularityCategory.allCases) { category in
Button { Button {
@@ -98,13 +122,27 @@ struct VerbListView: View {
} }
} }
} }
Section("Reflexive") {
Button {
reflexiveOnly.toggle()
} label: {
Label("Reflexive verbs only", systemImage: reflexiveOnly ? "checkmark" : "arrow.triangle.2.circlepath")
}
}
} label: { } label: {
Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
} }
} }
} }
.task { loadVerbs() } .task {
.onAppear { loadVerbs() } loadVerbs()
loadProgress()
}
.onAppear {
loadVerbs()
loadProgress()
}
} detail: { } detail: {
if let verb = selectedVerb { if let verb = selectedVerb {
VerbDetailView(verb: verb) VerbDetailView(verb: verb)
@@ -115,15 +153,15 @@ struct VerbListView: View {
} }
private var hasActiveFilter: Bool { private var hasActiveFilter: Bool {
selectedLevel != nil || selectedIrregularity != nil !allLevelsActive || selectedIrregularity != nil || reflexiveOnly
} }
@ViewBuilder @ViewBuilder
private var activeFilterBar: some View { private var activeFilterBar: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
if let level = selectedLevel { if !allLevelsActive {
filterChip(text: level.capitalized, systemImage: "graduationcap") { filterChip(text: levelChipLabel, systemImage: "graduationcap") {
selectedLevel = nil setAllLevels(enabled: true)
} }
} }
if let cat = selectedIrregularity { if let cat = selectedIrregularity {
@@ -131,6 +169,11 @@ struct VerbListView: View {
selectedIrregularity = nil selectedIrregularity = nil
} }
} }
if reflexiveOnly {
filterChip(text: "Reflexive", systemImage: "arrow.triangle.2.circlepath") {
reflexiveOnly = false
}
}
Spacer() Spacer()
Text("\(filteredVerbs.count)") Text("\(filteredVerbs.count)")
.font(.caption.monospacedDigit()) .font(.caption.monospacedDigit())
@@ -174,6 +217,40 @@ struct VerbListView: View {
print("[VerbListView] loaded \(verbs.count) verbs, \(irregularityByVerbId.count) flagged irregular (container: \(ObjectIdentifier(container)))") 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>] { private func buildIrregularityIndex(context: ModelContext) -> [Int: Set<IrregularityCategory>] {
let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? [] let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? []
var index: [Int: Set<IrregularityCategory>] = [:] var index: [Int: Set<IrregularityCategory>] = [:]
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
+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) |
+7 -12
View File
@@ -41,24 +41,19 @@ struct CombinedProvider: TimelineProvider {
private func fetchWordOfDay(for date: Date) -> WordOfDay? { private func fetchWordOfDay(for date: Date) -> WordOfDay? {
guard let localURL = SharedStore.localStoreURL() else { return nil } guard let localURL = SharedStore.localStoreURL() else { return nil }
// MUST declare all 7 local entities to match the main app's schema. // Open the store with the SAME schema as the main app. A subset schema
// Declaring a subset would cause SwiftData to destructively migrate the // would make SwiftData destructively migrate the store on open and drop
// store on open, dropping the entities not listed here (this is how we // every unlisted table (this is how widget refreshes kept wiping the
// previously lost all TextbookChapter rows on every widget refresh). // bundled Book rows, and TextbookChapter before them).
let schema = Schema(SharedStore.localSchemaModels)
let config = ModelConfiguration( let config = ModelConfiguration(
"local", "local",
schema: Schema([ schema: schema,
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
]),
url: localURL, url: localURL,
cloudKitDatabase: .none cloudKitDatabase: .none
) )
guard let container = try? ModelContainer( guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self, for: schema,
TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
configurations: config configurations: config
) else { return nil } ) else { return nil }
+7 -12
View File
@@ -32,24 +32,19 @@ struct WordOfDayProvider: TimelineProvider {
private func fetchWordOfDay(for date: Date) -> WordOfDay? { private func fetchWordOfDay(for date: Date) -> WordOfDay? {
guard let localURL = SharedStore.localStoreURL() else { return nil } guard let localURL = SharedStore.localStoreURL() else { return nil }
// MUST declare all 7 local entities to match the main app's schema. // Open the store with the SAME schema as the main app. A subset schema
// Declaring a subset would cause SwiftData to destructively migrate the // would make SwiftData destructively migrate the store on open and drop
// store on open, dropping the entities not listed here (this is how we // every unlisted table (this is how widget refreshes kept wiping the
// previously lost all TextbookChapter rows on every widget refresh). // bundled Book rows, and TextbookChapter before them).
let schema = Schema(SharedStore.localSchemaModels)
let config = ModelConfiguration( let config = ModelConfiguration(
"local", "local",
schema: Schema([ schema: schema,
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
]),
url: localURL, url: localURL,
cloudKitDatabase: .none cloudKitDatabase: .none
) )
guard let container = try? ModelContainer( guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self, for: schema,
TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
configurations: config configurations: config
) else { return nil } ) else { return nil }
+1
View File
@@ -0,0 +1 @@
build/
+102
View File
@@ -0,0 +1,102 @@
# Books pipeline
Turns any EPUB into a chapter-structured JSON file the app bundles and reads.
## TL;DR
```bash
cd Conjuga/Scripts/books
./run.sh /path/to/book.epub --slug my-book-slug
```
This runs Phase 1 (extract) and Phase 2 (manifest jobs), then stops and tells you how many translation jobs are pending. Run those via Claude Code subagents (Phase 2.5 below), then re-run `./run.sh` to bundle the final file.
## Phases
| Phase | Script | What it does | Output |
|---|---|---|---|
| 1 | `extract_epub.py` | Unzip the EPUB, walk `content.opf` spine + `toc.ncx` navMap, group HTML files into chapters, strip HTML→text. | `build/<slug>/chapters.json` |
| 2 | `translate_chapters.py` | Split each chapter into ~30-paragraph translation batches. Each batch becomes a job with its own input/output file. **Resumable**: jobs whose output file already exists are skipped. | `build/<slug>/jobs/<jobid>.input.json` + `_pending.txt` |
| 2b | `build_glossary.py` | Tokenize every Spanish paragraph the same way the app does, collect the distinct words with example sentences, split into ~150-word glossary batches. **Resumable** the same way. | `build/<slug>/glossary/<jobid>.input.json` + `_pending.txt` |
| 2.5 | Claude Code subagents | Drain **both** manifests: translate the chapter jobs *and* the glossary jobs, writing each job's `<jobid>.output.json`. See "Running translations" below. | `build/<slug>/{jobs,glossary}/<jobid>.output.json` |
| 3 | `bundle_book.py` | Merge `chapters.json` + every translation `*.output.json` + every glossary `*.output.json` into the final bundled JSON the app reads. | `Conjuga/Conjuga/book_<slug>.json` |
`run.sh` chains 1 → 2 → 2b → 3. If Phase 2 or 2b produces pending jobs, Phase 3 still runs but bundles with placeholders so you can preview app structure before the LLM passes complete. Re-running `run.sh` after subagents fill in the outputs gives you the real bundled file.
The glossary is the book reader's primary word-lookup source: every distinct word translated once, in context, so taps are instant, cover the whole book, and don't mis-resolve homographs (e.g. "como" as the conjunction vs. the verb *comer*). This phase is a permanent part of the pipeline — every book imported this way gets a glossary.
## Adding a new book
1. **Drop the EPUB** anywhere on disk.
2. **Run Phase 1+2**:
```bash
cd Conjuga/Scripts/books
./run.sh /path/to/book.epub --slug my-book
```
Sanity-check the chapter list it prints. If chapter grouping looks wrong (e.g. an EPUB without a usable `toc.ncx`), `extract_epub.py` will need a fallback heuristic — see "Open assumptions" below.
3. **Run translations** (Phase 2.5). The default approach is to spawn Claude Code subagents from inside a Claude Code session pointed at this repo:
There are **two** manifests to drain — translation and glossary:
- `build/<slug>/jobs/_pending.txt` with prompt `build/<slug>/jobs/_prompt_template.md`
- `build/<slug>/glossary/_pending.txt` with prompt `build/<slug>/glossary/_prompt_template.md`
For each pending job ID, hand a subagent the matching prompt with `<JOB_INPUT_PATH>` / `<JOB_OUTPUT_PATH>` filled in. The subagent reads the input, produces the translation/glossary, and writes the output. Resumable — interrupted runs just leave the missing job IDs in `_pending.txt`.
Cluster jobs into agent batches of ~510 jobs each to keep per-agent context manageable. ~5 parallel agents is a good throughput target.
4. **Bundle**:
```bash
./run.sh /path/to/book.epub --slug my-book # re-running pulls in the new outputs
# or directly:
python3 bundle_book.py my-book --require-all
```
`--require-all` will fail loudly if any job is still missing.
5. **Bump `bookDataVersion`** in `DataLoader.swift` so the in-app store re-seeds the new book on next launch (or any time you re-run with new translations).
6. **Verify the file is bundled** in `Conjuga.xcodeproj`. The script writes `book_<slug>.json` into `Conjuga/Conjuga/Resources/`; if that folder is part of a recursive group reference, Xcode picks it up automatically. Otherwise, add it manually or via the `xcodeproj` ruby gem.
## File layout
```
Conjuga/Scripts/books/
├── extract_epub.py # Phase 1
├── translate_chapters.py # Phase 2
├── build_glossary.py # Phase 2b
├── bundle_book.py # Phase 3
├── run.sh # Orchestrator
└── build/ # gitignored
└── <slug>/
├── chapters.json
├── jobs/ # translation jobs
│ ├── _pending.txt
│ ├── _prompt_template.md
│ ├── ch01_b00.input.json
│ ├── ch01_b00.output.json
│ └── ...
└── glossary/ # glossary jobs (Phase 2b)
├── _pending.txt
├── _prompt_template.md
├── gloss_b00.input.json
├── gloss_b00.output.json
└── ...
```
The final output (`book_<slug>.json`) lives at `Conjuga/Conjuga/book_<slug>.json` so the iOS app bundle includes it. (Existing `textbook_data.json` / `conjuga_data.json` use the same layout — files in the app target root rather than a Resources subgroup.)
## Open assumptions
- **TOC drives chapter boundaries.** If an EPUB ships without a usable `toc.ncx`, or the navMap is too granular (e.g. one navPoint per page), `extract_epub.py` will need a fallback that groups by `<h1>` headings in spine order.
- **Spanish bold tags = inline emphasis.** The Olly Richards books bold vocab hints inside paragraphs. We strip the bold and let the in-app dictionary lookup handle definitions instead. If a future book uses bold for something else (titles, etc.), revisit.
- **Translation is per-paragraph 1:1.** Subagents must preserve paragraph count and order. `bundle_book.py` will warn + pad/truncate if a job's output array length doesn't match its input — but that's a sign the subagent misbehaved.
## Out of scope (intentional)
- OCR of vocab image tables (use `Scripts/textbook/` if your book is image-heavy).
- Exercise extraction (textbook pipeline).
- Per-occurrence word sense disambiguation. The glossary has one entry per
distinct word, translated in context; a word genuinely used in two senses in
the same book gets its dominant sense. The runtime `DictionaryService` + the
on-device LLM remain as fallbacks for anything the glossary misses.
- Cover image extraction (covers are derived from a color hash in the app for now).
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Phase 2b — build a per-book glossary job manifest.
Scans chapters.json, tokenizes every Spanish paragraph the SAME way the iOS app
does (whitespace split, lowercase, strip leading/trailing punctuation), collects
the distinct words with a few example sentences each, and writes batched
glossary jobs that Claude Code subagents can translate in parallel. Resumable:
jobs whose output file already exists are skipped.
Usage:
python3 build_glossary.py <slug> [--batch-size N] [--max-examples N]
[--build BUILD_DIR]
Inputs:
BUILD_DIR/<slug>/chapters.json (from extract_epub.py)
Outputs:
BUILD_DIR/<slug>/glossary/<jobid>.input.json (one per batch — read by subagents)
BUILD_DIR/<slug>/glossary/_pending.txt (job IDs still missing output)
BUILD_DIR/<slug>/glossary/_prompt_template.md (prompt for each subagent)
Job input shape (.input.json):
{"jobId": "gloss_b00",
"words": [{"word": "taza", "examples": ["...", "..."]}, ...]}
Subagents must write <jobid>.output.json with shape:
{"jobId": "gloss_b00",
"entries": [{"word": "taza", "baseForm": "taza",
"english": "cup", "partOfSpeech": "noun"}, ...]}
`entries` must contain exactly one object per input word.
"""
from __future__ import annotations
import argparse
import json
import re
import unicodedata
from pathlib import Path
PROMPT_TEMPLATE = """\
You are building a Spanish->English glossary for a language-learning app.
Input file: {input_path}
Output file: {output_path}
Read the input file. It contains a JSON object with a `words` array; each item
has a `word` (a lowercase Spanish word exactly as it appears in a book) and
`examples` (sentences from the book that use that word).
For EACH word, produce one entry:
- baseForm: the dictionary base form -- infinitive for verbs, masculine
singular for nouns/adjectives, the word itself for invariant words.
- english: a concise English translation (1-4 words). Use the sense the word
carries in the example sentences. Many Spanish words are both a verb form
AND a function word -- e.g. "como" is "I eat" (verb) and "as/like"
(conjunction). Choose the meaning shown in the examples, not the most common
dictionary sense.
- partOfSpeech: one of verb, noun, adjective, adverb, pronoun, preposition,
conjunction, article, interjection, numeral, proper noun, other.
Write the output file as JSON with this exact shape:
{{"jobId": "<the jobId from the input>", "entries": [
{{"word": "...", "baseForm": "...", "english": "...", "partOfSpeech": "..."}}
]}}
`entries` MUST contain exactly one object per input word, cover every word, and
echo each `word` back verbatim. Write nothing else to disk and produce no other
output.
"""
SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+")
def is_punct(ch: str) -> bool:
"""True for any Unicode punctuation — matches Swift's .punctuationCharacters."""
return unicodedata.category(ch).startswith("P")
def clean_word(token: str) -> str:
"""Mirror BookReaderView.cleanWord: lowercase, strip leading/trailing
punctuation, trim whitespace. Accents are preserved (no folding)."""
t = token.lower()
start, end = 0, len(t)
while start < end and is_punct(t[start]):
start += 1
while end > start and is_punct(t[end - 1]):
end -= 1
return t[start:end].strip()
def has_letter(s: str) -> bool:
return any(c.isalpha() for c in s)
def split_sentences(paragraph: str) -> list[str]:
parts = SENTENCE_SPLIT.split(paragraph.strip())
return [p.strip() for p in parts if p.strip()]
def is_english_front_matter(chapter: dict, threshold: float = 0.5) -> bool:
"""True when most of a chapter's paragraphs are untranslated — i.e. it is
English front matter (Preface, reading guide, …) rather than Spanish story
content. Story chapters still have *some* identical lines (verbatim
`word = meaning` vocab entries), so a majority threshold separates them:
front matter runs ~70-100% identical, stories ~25-35%. Only detectable once
paragraphsEN is populated; raw extracted chapters carry none, so nothing is
skipped on a fresh book's first pass."""
es = [p.strip() for p in chapter.get("paragraphsES", [])]
en = [p.strip() for p in chapter.get("paragraphsEN", [])]
if not en or len(en) != len(es) or not es:
return False
identical = sum(1 for a, b in zip(es, en) if a == b)
return identical / len(es) > threshold
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("slug")
parser.add_argument("--batch-size", type=int, default=150)
parser.add_argument("--max-examples", type=int, default=3)
parser.add_argument("--build", type=Path, default=Path("build"))
args = parser.parse_args()
base = args.build / args.slug
chapters = json.loads((base / "chapters.json").read_text(encoding="utf-8"))
gloss_dir = base / "glossary"
gloss_dir.mkdir(parents=True, exist_ok=True)
examples: dict[str, list[str]] = {}
first_seen: dict[str, int] = {}
order = 0
skipped_front_matter = 0
for ch in chapters["chapters"]:
if is_english_front_matter(ch):
skipped_front_matter += 1
continue
for paragraph in ch.get("paragraphsES", []):
for sentence in split_sentences(paragraph):
cleaned = {clean_word(tok) for tok in sentence.split()}
for w in cleaned:
if not w or not has_letter(w):
continue
if w not in first_seen:
first_seen[w] = order
order += 1
examples[w] = []
bucket = examples[w]
if len(bucket) < args.max_examples and sentence not in bucket:
bucket.append(sentence)
words = sorted(examples.keys(), key=lambda w: first_seen[w])
pending: list[str] = []
completed: list[str] = []
total_jobs = 0
for offset in range(0, len(words), args.batch_size):
chunk = words[offset : offset + args.batch_size]
job_id = f"gloss_b{offset // args.batch_size:02d}"
input_path = gloss_dir / f"{job_id}.input.json"
output_path = gloss_dir / f"{job_id}.output.json"
input_path.write_text(
json.dumps(
{
"jobId": job_id,
"words": [{"word": w, "examples": examples[w]} for w in chunk],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
total_jobs += 1
(completed if output_path.exists() else pending).append(job_id)
(gloss_dir / "_pending.txt").write_text(
"\n".join(pending) + ("\n" if pending else ""), encoding="utf-8"
)
(gloss_dir / "_prompt_template.md").write_text(
PROMPT_TEMPLATE.format(
input_path="<JOB_INPUT_PATH>", output_path="<JOB_OUTPUT_PATH>"
),
encoding="utf-8",
)
print(f"Skipped front matter: {skipped_front_matter} chapter(s)")
print(f"Distinct words: {len(words)}")
print(f"Total glossary jobs: {total_jobs}")
print(f" Completed: {len(completed)}")
print(f" Pending: {len(pending)}")
print(f"Manifest at: {gloss_dir / '_pending.txt'}")
print(f"Prompt template at: {gloss_dir / '_prompt_template.md'}")
if __name__ == "__main__":
main()
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Merge chapters.json + per-job translation outputs into the final bundled
book_<slug>.json that the iOS app reads from its bundle.
Usage:
python3 bundle_book.py <slug> [--build BUILD_DIR] [--dest DEST_DIR] [--require-all]
Inputs:
BUILD_DIR/<slug>/chapters.json
BUILD_DIR/<slug>/jobs/*.output.json (from translation subagents)
BUILD_DIR/<slug>/glossary/*.output.json (from glossary subagents, Phase 2b)
Output:
DEST_DIR/book_<slug>.json
{
"slug": "...",
"title": "...",
"author": "...",
"language": "...",
"chapters": [
{"id": "ch1", "number": 1, "title": "Preface",
"paragraphsES": ["...", ...],
"paragraphsEN": ["...", ...]},
...
],
"glossary": {
"taza": {"baseForm": "taza", "english": "cup", "partOfSpeech": "noun"},
...
}
}
If --require-all is passed, the script fails if any translation OR glossary job
is missing its output. Otherwise it fills missing translations with empty
strings, leaves missing glossary entries out, and warns.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
DEFAULT_DEST = Path("../../Conjuga")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("slug")
parser.add_argument("--build", type=Path, default=Path("build"))
parser.add_argument("--dest", type=Path, default=None)
parser.add_argument("--require-all", action="store_true")
args = parser.parse_args()
base = args.build / args.slug
chapters = json.loads((base / "chapters.json").read_text(encoding="utf-8"))
jobs_dir = base / "jobs"
# Index translation jobs by chapter -> ordered (offset, paragraphsEN).
chapter_translations: dict[int, list[tuple[int, list[str]]]] = {}
missing: list[str] = []
for input_path in sorted(jobs_dir.glob("*.input.json")):
job_id = input_path.stem.removesuffix(".input")
input_data = json.loads(input_path.read_text(encoding="utf-8"))
output_path = jobs_dir / f"{job_id}.output.json"
if not output_path.exists():
missing.append(job_id)
continue
output_data = json.loads(output_path.read_text(encoding="utf-8"))
paragraphs_en = output_data.get("paragraphsEN", [])
expected = len(input_data["paragraphsES"])
if len(paragraphs_en) != expected:
print(
f"WARN: {job_id} length mismatch — got {len(paragraphs_en)}, "
f"expected {expected}. Padding/truncating.",
file=sys.stderr,
)
if len(paragraphs_en) < expected:
paragraphs_en = paragraphs_en + [""] * (expected - len(paragraphs_en))
else:
paragraphs_en = paragraphs_en[:expected]
chapter_translations.setdefault(input_data["chapter"], []).append(
(input_data["rangeStart"], paragraphs_en)
)
if missing:
msg = f"{len(missing)} translation job(s) missing output: {missing[:5]}{'...' if len(missing) > 5 else ''}"
if args.require_all:
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
print(f"WARN: {msg} — using empty strings for those paragraphs.", file=sys.stderr)
# Glossary (Phase 2b) — merge every glossary job's entries into one map
# keyed by the cleaned word the app looks up.
glossary_dir = base / "glossary"
glossary: dict[str, dict] = {}
glossary_missing: list[str] = []
if glossary_dir.exists():
for input_path in sorted(glossary_dir.glob("*.input.json")):
job_id = input_path.stem.removesuffix(".input")
output_path = glossary_dir / f"{job_id}.output.json"
if not output_path.exists():
glossary_missing.append(job_id)
continue
output_data = json.loads(output_path.read_text(encoding="utf-8"))
for entry in output_data.get("entries", []):
word = (entry.get("word") or "").strip()
if not word:
continue
glossary[word] = {
"baseForm": entry.get("baseForm") or word,
"english": entry.get("english") or "",
"partOfSpeech": entry.get("partOfSpeech") or "",
}
if glossary_missing:
msg = f"{len(glossary_missing)} glossary job(s) missing output: {glossary_missing[:5]}{'...' if len(glossary_missing) > 5 else ''}"
if args.require_all:
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
print(f"WARN: {msg} — glossary will be incomplete.", file=sys.stderr)
bundled_chapters: list[dict] = []
for ch in chapters["chapters"]:
translations = sorted(chapter_translations.get(ch["number"], []))
paragraphs_en: list[str] = []
for _, en_chunk in translations:
paragraphs_en.extend(en_chunk)
# Pad to match ES length if jobs were missing for parts of this chapter.
if len(paragraphs_en) < len(ch["paragraphsES"]):
paragraphs_en += [""] * (len(ch["paragraphsES"]) - len(paragraphs_en))
elif len(paragraphs_en) > len(ch["paragraphsES"]):
paragraphs_en = paragraphs_en[: len(ch["paragraphsES"])]
bundled_chapters.append(
{
"id": ch["id"],
"number": ch["number"],
"title": ch["title"],
"paragraphsES": ch["paragraphsES"],
"paragraphsEN": paragraphs_en,
}
)
payload = {
"slug": chapters["slug"],
"title": chapters["title"],
"author": chapters["author"],
"language": chapters["language"],
"chapters": bundled_chapters,
"glossary": glossary,
}
dest_dir = (args.dest or DEFAULT_DEST).resolve()
dest_dir.mkdir(parents=True, exist_ok=True)
out_path = dest_dir / f"book_{args.slug}.json"
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Wrote {out_path}")
print(f" Chapters: {len(bundled_chapters)}")
print(f" Translated jobs: {sum(len(v) for v in chapter_translations.values())} / {sum(len(v) for v in chapter_translations.values()) + len(missing)}")
print(f" Glossary words: {len(glossary)}")
if __name__ == "__main__":
main()
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""Parse an EPUB into chapters.json for the in-app Books feature.
Usage:
python3 extract_epub.py <epub_path> [--slug SLUG] [--out OUT_DIR]
Defaults:
SLUG derived from the EPUB filename (lowercased, dashed)
OUT_DIR ./build/<slug>
Output:
OUT_DIR/chapters.json
{
"title": "...",
"author": "...",
"language": "...",
"slug": "...",
"chapters": [
{"id": "ch1", "number": 1, "title": "Preface",
"paragraphsES": ["...", "..."]},
...
]
}
How chapter grouping works:
1. Read content.opf manifest (id -> href) and spine (ordered idrefs).
2. Read toc.ncx navMap to get the ordered list of chapter (title, first-href).
3. For each chapter, claim every spine file from its first href up to (but
not including) the next chapter's first href.
4. For each file in the chapter's range, parse <p> elements, strip tags,
normalise whitespace + smart quotes, drop empties.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import unicodedata
import warnings
import zipfile
from pathlib import Path
from typing import Iterable
from xml.etree import ElementTree as ET
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
NS = {
"opf": "http://www.idpf.org/2007/opf",
"dc": "http://purl.org/dc/elements/1.1/",
"ncx": "http://www.daisy.org/z3986/2005/ncx/",
"xhtml": "http://www.w3.org/1999/xhtml",
}
def _slugify(s: str) -> str:
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii")
s = re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower()
return s or "book"
def _normalise(text: str) -> str:
# Collapse runs of whitespace, normalise smart quotes to plain ones.
text = text.replace(" ", " ")
text = re.sub(r"\s+", " ", text).strip()
text = re.sub(r"\s+([.,;:!?…])", r"\1", text)
text = re.sub(r"([¡¿])\s+", r"\1", text)
return text
def _read_zip_text(zf: zipfile.ZipFile, path: str) -> str:
return zf.read(path).decode("utf-8")
def _container_root(zf: zipfile.ZipFile) -> str:
container = ET.fromstring(_read_zip_text(zf, "META-INF/container.xml"))
rootfile = container.find(".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile")
if rootfile is None:
raise RuntimeError("Missing rootfile entry in META-INF/container.xml")
return rootfile.attrib["full-path"]
def _parse_opf(zf: zipfile.ZipFile, opf_path: str):
text = _read_zip_text(zf, opf_path)
root = ET.fromstring(text)
title = (root.findtext(".//dc:title", default="", namespaces=NS) or "").strip()
author = (root.findtext(".//dc:creator", default="", namespaces=NS) or "").strip()
language = (root.findtext(".//dc:language", default="", namespaces=NS) or "").strip()
manifest: dict[str, str] = {}
for item in root.findall("opf:manifest/opf:item", NS):
manifest[item.attrib["id"]] = item.attrib["href"]
spine: list[str] = []
for itemref in root.findall("opf:spine/opf:itemref", NS):
spine.append(itemref.attrib["idref"])
ncx_id = root.find("opf:spine", NS).attrib.get("toc") if root.find("opf:spine", NS) is not None else None
ncx_href = manifest.get(ncx_id) if ncx_id else None
return {
"title": title,
"author": author,
"language": language,
"manifest": manifest,
"spine": spine,
"ncx_href": ncx_href,
"opf_dir": str(Path(opf_path).parent) if "/" in opf_path else "",
}
def _parse_ncx(zf: zipfile.ZipFile, ncx_path: str) -> list[dict]:
text = _read_zip_text(zf, ncx_path)
root = ET.fromstring(text)
chapters: list[dict] = []
for nav in root.findall("ncx:navMap/ncx:navPoint", NS):
title = (nav.findtext("ncx:navLabel/ncx:text", default="", namespaces=NS) or "").strip()
content = nav.find("ncx:content", NS)
src = content.attrib.get("src", "") if content is not None else ""
# Strip the anchor — we want the file path only.
href = src.split("#", 1)[0]
chapters.append({"title": title, "href": href})
return chapters
def _resolve_zip_path(base_dir: str, href: str) -> str:
if not base_dir:
return href
return f"{base_dir}/{href}".lstrip("/")
def _extract_paragraphs(zf: zipfile.ZipFile, zip_path: str) -> list[str]:
try:
html = _read_zip_text(zf, zip_path)
except KeyError:
return []
soup = BeautifulSoup(html, "lxml")
paragraphs: list[str] = []
# Walk <p> and <li> in document order so vocab bullets (rendered as
# <ul><li>...</li></ul> in this EPUB family) are kept alongside narrative
# paragraphs. `<li>` rolls up its inline <b>/<span> children via get_text.
for el in soup.find_all(["p", "li"]):
text = _normalise(el.get_text(" ", strip=True))
if not text:
continue
paragraphs.append(text)
return paragraphs
def _chapter_files(
spine_files: list[str], chapter_hrefs: list[str]
) -> list[list[str]]:
"""Slice the spine into one list of files per chapter, using the chapter's
first href as the chapter boundary. Files before the first chapter (e.g.
cover, titlepage) are dropped."""
boundaries: list[int] = []
for href in chapter_hrefs:
try:
idx = spine_files.index(href)
except ValueError:
boundaries.append(-1)
continue
boundaries.append(idx)
ranges: list[list[str]] = []
for i, start in enumerate(boundaries):
if start < 0:
ranges.append([])
continue
end = len(spine_files)
for next_start in boundaries[i + 1:]:
if next_start >= 0:
end = next_start
break
ranges.append(spine_files[start:end])
return ranges
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("epub", type=Path)
parser.add_argument("--slug", default=None)
parser.add_argument("--out", type=Path, default=None)
args = parser.parse_args()
if not args.epub.exists():
print(f"EPUB not found: {args.epub}", file=sys.stderr)
sys.exit(2)
with zipfile.ZipFile(args.epub) as zf:
opf_path = _container_root(zf)
opf = _parse_opf(zf, opf_path)
if not opf["ncx_href"]:
print("No NCX found in spine; cannot derive chapter structure.", file=sys.stderr)
sys.exit(3)
ncx_path = _resolve_zip_path(opf["opf_dir"], opf["ncx_href"])
toc = _parse_ncx(zf, ncx_path)
spine_files = [
_resolve_zip_path(opf["opf_dir"], opf["manifest"].get(idref, ""))
for idref in opf["spine"]
]
chapter_hrefs = [_resolve_zip_path(opf["opf_dir"], c["href"]) for c in toc]
chapter_file_ranges = _chapter_files(spine_files, chapter_hrefs)
chapters_out: list[dict] = []
for i, (meta, files) in enumerate(zip(toc, chapter_file_ranges), start=1):
paragraphs: list[str] = []
for f in files:
paragraphs.extend(_extract_paragraphs(zf, f))
# Drop leading paragraph(s) that just echo the chapter title — the
# title is already stored separately.
title_norm = _normalise(meta["title"]).lower()
while paragraphs and _normalise(paragraphs[0]).lower() == title_norm:
paragraphs.pop(0)
chapters_out.append(
{
"id": f"ch{i}",
"number": i,
"title": meta["title"],
"paragraphsES": paragraphs,
}
)
slug = args.slug or _slugify(opf["title"]) or args.epub.stem
out_dir = args.out or (Path("build") / slug)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / "chapters.json"
payload = {
"title": opf["title"],
"author": opf["author"],
"language": opf["language"],
"slug": slug,
"chapters": chapters_out,
}
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
total_paragraphs = sum(len(c["paragraphsES"]) for c in chapters_out)
print(f"Wrote {out_path}")
print(f" Title: {opf['title']}")
print(f" Author: {opf['author']}")
print(f" Chapters: {len(chapters_out)}")
print(f" Paragraphs: {total_paragraphs}")
for ch in chapters_out:
print(f" ch{ch['number']:02d} {len(ch['paragraphsES']):4d}{ch['title']}")
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Orchestrate the books pipeline: EPUB -> chapters.json -> per-chapter job
# manifest -> (translation by Claude Code subagents) -> bundled book_<slug>.json.
#
# This script DOES NOT run the LLM translation pass. After Phase 2 it stops
# and prints how many jobs are pending. Use Claude Code subagents (or a fresh
# session per the README) to fill in build/<slug>/jobs/*.output.json, then
# re-run this script — it will pick up where it left off via Phase 3.
#
# Usage:
# ./run.sh <epub_path> [--slug SLUG] [--batch-size N]
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
if [[ $# -lt 1 ]]; then
echo "usage: $0 <epub_path> [--slug SLUG] [--batch-size N]"
exit 2
fi
EPUB="$1"; shift
SLUG=""
BATCH_SIZE="30"
GLOSSARY_BATCH_SIZE="150"
while [[ $# -gt 0 ]]; do
case "$1" in
--slug) SLUG="$2"; shift 2 ;;
--batch-size) BATCH_SIZE="$2"; shift 2 ;;
--glossary-batch-size) GLOSSARY_BATCH_SIZE="$2"; shift 2 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
EPUB_ABS="$(cd "$(dirname "$EPUB")" && pwd)/$(basename "$EPUB")"
echo "=== Phase 1: extract_epub.py ==="
if [[ -n "$SLUG" ]]; then
python3 extract_epub.py "$EPUB_ABS" --slug "$SLUG"
else
python3 extract_epub.py "$EPUB_ABS"
fi
# If --slug wasn't passed, recover the slug from the chapters file just written.
if [[ -z "$SLUG" ]]; then
SLUG=$(python3 -c "import json,glob; p=sorted(glob.glob('build/*/chapters.json'), key=lambda x: -__import__('os').path.getmtime(x))[0]; print(json.load(open(p))['slug'])")
fi
echo
echo "=== Phase 2: translate_chapters.py ==="
python3 translate_chapters.py "$SLUG" --batch-size "$BATCH_SIZE"
PENDING_FILE="build/$SLUG/jobs/_pending.txt"
PENDING_COUNT=$(wc -l < "$PENDING_FILE" | tr -d ' ')
echo
echo "=== Phase 2b: build_glossary.py ==="
python3 build_glossary.py "$SLUG" --batch-size "$GLOSSARY_BATCH_SIZE"
GLOSS_PENDING_FILE="build/$SLUG/glossary/_pending.txt"
GLOSS_PENDING_COUNT=$(wc -l < "$GLOSS_PENDING_FILE" | tr -d ' ')
TOTAL_PENDING=$((PENDING_COUNT + GLOSS_PENDING_COUNT))
echo
echo "=== Phase 3: bundle_book.py ==="
if [[ "$TOTAL_PENDING" -gt 0 ]]; then
echo " $PENDING_COUNT translation job(s) and $GLOSS_PENDING_COUNT glossary job(s) still pending."
echo " Run the Claude Code subagent step (see README.md) for BOTH manifests:"
echo " build/$SLUG/jobs/_pending.txt (translation)"
echo " build/$SLUG/glossary/_pending.txt (glossary)"
echo " then re-run this script. Bundling with placeholders so you can preview now."
python3 bundle_book.py "$SLUG"
else
python3 bundle_book.py "$SLUG" --require-all
fi
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Split chapters.json into translation jobs that Claude Code subagents can
process in parallel. Resumable: jobs whose output file already exists are
skipped.
Usage:
python3 translate_chapters.py <slug> [--batch-size N] [--build BUILD_DIR]
Inputs:
BUILD_DIR/<slug>/chapters.json (from extract_epub.py)
Outputs:
BUILD_DIR/<slug>/jobs/<jobid>.input.json (one per batch — read by subagents)
BUILD_DIR/<slug>/jobs/_pending.txt (list of job IDs still missing output)
BUILD_DIR/<slug>/jobs/_prompt_template.md (prompt the orchestrator hands each subagent)
Job layout (.input.json):
{
"jobId": "ch06_b00",
"chapter": 6,
"chapterTitle": "1. El Castillo",
"rangeStart": 0,
"rangeEnd": 30,
"paragraphsES": ["...", "..."]
}
Subagents must write `<jobid>.output.json` with shape:
{"jobId": "ch06_b00", "paragraphsEN": ["...", "..."]}
The output array MUST have the same length as paragraphsES, in the same order.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
PROMPT_TEMPLATE = """\
You are translating a chunk of a Spanish-language book into English for a
language-learning app.
Input file: {input_path}
Output file: {output_path}
Read the input file. It contains a JSON object with a `paragraphsES` array.
Translate each paragraph into natural English. Preserve meaning, tone, and
dialogue markers (—, , ¡, ¿) as appropriate for the English output. Keep
the same number of paragraphs in the same order.
Notes for translation quality:
- This is a beginner Spanish reader, so prefer plain natural English over
literary flourish.
- Preserve proper nouns (character names, place names) verbatim.
- Convert Spanish dialogue dashes (, —) to English-style quotation marks
ONLY if it reads more naturally; otherwise keep them as em-dashes.
- Do NOT add explanatory parentheticals; the in-app dictionary handles
per-word lookup.
- Some paragraphs are vocabulary entries shaped like `palabra = meaning`
(e.g. `alto = tall`, `el dueño = owner`). Keep these verbatim — both the
Spanish word and its English gloss already coexist on the line, and the
bilingual reader UI shows the same line in both views.
Write the output as JSON with shape:
{{"jobId": "<the jobId from the input>", "paragraphsEN": [...]}}
The `paragraphsEN` array MUST be the same length and order as `paragraphsES`
in the input. Write nothing else to disk and produce no other output.
"""
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("slug")
parser.add_argument("--batch-size", type=int, default=30)
parser.add_argument("--build", type=Path, default=Path("build"))
args = parser.parse_args()
base = args.build / args.slug
chapters_path = base / "chapters.json"
jobs_dir = base / "jobs"
jobs_dir.mkdir(parents=True, exist_ok=True)
data = json.loads(chapters_path.read_text(encoding="utf-8"))
pending: list[str] = []
completed: list[str] = []
total_jobs = 0
for ch in data["chapters"]:
paragraphs = ch["paragraphsES"]
if not paragraphs:
continue
for offset in range(0, len(paragraphs), args.batch_size):
chunk = paragraphs[offset : offset + args.batch_size]
job_id = f"ch{ch['number']:02d}_b{offset // args.batch_size:02d}"
input_path = jobs_dir / f"{job_id}.input.json"
output_path = jobs_dir / f"{job_id}.output.json"
input_path.write_text(
json.dumps(
{
"jobId": job_id,
"chapter": ch["number"],
"chapterTitle": ch["title"],
"rangeStart": offset,
"rangeEnd": offset + len(chunk),
"paragraphsES": chunk,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
total_jobs += 1
if output_path.exists():
completed.append(job_id)
else:
pending.append(job_id)
(jobs_dir / "_pending.txt").write_text("\n".join(pending) + ("\n" if pending else ""))
(jobs_dir / "_prompt_template.md").write_text(
PROMPT_TEMPLATE.format(
input_path="<JOB_INPUT_PATH>",
output_path="<JOB_OUTPUT_PATH>",
),
encoding="utf-8",
)
print(f"Total translation jobs: {total_jobs}")
print(f" Completed: {len(completed)}")
print(f" Pending: {len(pending)}")
print(f"Manifest at: {jobs_dir / '_pending.txt'}")
print(f"Prompt template at: {jobs_dir / '_prompt_template.md'}")
if __name__ == "__main__":
main()
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Generate a markdown report of every curated YouTube video referenced by the app.
Reads Conjuga/youtube_videos.json, queries yt-dlp for metadata on each video,
and emits Conjuga/youtube_videos.md with tables for tense guides and grammar
notes plus a list of topics with no curated video.
Usage:
python3 Scripts/generate_videos_markdown.py
Requires `yt-dlp` on PATH. Videos that have been taken down or made private
appear in the tables with an "(unavailable)" marker in the title column.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
VIDEOS_JSON = REPO_ROOT / "Conjuga" / "youtube_videos.json"
OUTPUT_MD = REPO_ROOT / "Conjuga" / "youtube_videos.md"
# The curated ids we expect — anything in the source file that's missing from
# the JSON shows up in the "missing" section at the bottom.
EXPECTED_TENSE_IDS = [
"ind_presente", "ind_preterito", "ind_imperfecto", "ind_futuro",
"ind_perfecto", "ind_pluscuamperfecto", "ind_futuro_perfecto",
"ind_preterito_anterior",
"cond_presente", "cond_perfecto",
"subj_presente", "subj_imperfecto_1", "subj_imperfecto_2",
"subj_perfecto", "subj_pluscuamperfecto_1", "subj_pluscuamperfecto_2",
"subj_futuro", "subj_futuro_perfecto",
"imp_afirmativo", "imp_negativo",
]
EXPECTED_GRAMMAR_IDS = [
"ser-vs-estar", "por-vs-para", "preterite-vs-imperfect",
"subjunctive-triggers", "reflexive-verbs", "object-pronouns",
"gustar-like-verbs", "comparatives-superlatives",
"conditional-if-clauses", "commands-imperative", "saber-vs-conocer",
"double-negatives", "adjective-placement", "tener-expressions",
"personal-a", "relative-pronouns", "future-vs-ir-a",
"accent-marks-stress", "se-constructions", "estar-gerund-progressive",
"spanish-suffixes", "common-irregular-verbs", "types-of-irregular-verbs",
"present-indicative-conjugation", "articles-and-gender",
"possessive-adjectives", "demonstrative-adjectives",
"greetings-farewells", "poder-infinitive", "al-del-contractions",
"prepositional-pronouns", "irregular-yo-verbs", "stem-changing-verbs",
"stressed-possessives", "present-perfect-tense", "future-perfect-tense",
]
def fetch_metadata(video_id: str) -> dict:
"""Return a dict of useful metadata fields for a single video.
On any yt-dlp failure (video removed, network issue, extraction break)
returns a dict with `unavailable=True` so the caller can mark the row.
"""
try:
result = subprocess.run(
["yt-dlp", "--skip-download", "--dump-json", "--no-warnings", "--", video_id],
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
return {"unavailable": True, "reason": "timeout"}
if result.returncode != 0:
# yt-dlp errors look like:
# "ERROR: [youtube] ID: <reason>. <cookie/help nag with URLs…>"
# Extract just <reason> and drop everything after the first "." so the
# markdown table stays readable. Help URLs contain colons so a naive
# split-on-colon grabs the wrong chunk.
reason = "yt-dlp failed"
pattern = re.compile(r"ERROR:\s*\[[^\]]+\]\s*[^:]+:\s*(.+)")
for line in result.stderr.strip().splitlines():
m = pattern.search(line)
if m:
reason = m.group(1).split(". ")[0].rstrip(".")
break
return {"unavailable": True, "reason": reason}
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return {"unavailable": True, "reason": "invalid json"}
return {
"unavailable": False,
"title": data.get("title") or "",
"uploader": data.get("uploader") or data.get("channel") or "",
"upload_date": data.get("upload_date") or "", # YYYYMMDD
"duration": data.get("duration"), # seconds
"view_count": data.get("view_count"),
"like_count": data.get("like_count"),
}
def fmt_duration(seconds: int | None) -> str:
if not seconds:
return ""
h, rem = divmod(int(seconds), 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
def fmt_date(raw: str) -> str:
if not raw or len(raw) != 8:
return ""
return f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}"
def fmt_int(n: int | None) -> str:
if n is None:
return ""
return f"{n:,}"
def render_row(key: str, curated: dict, meta: dict) -> str:
video_id = curated["videoId"]
url = f"https://www.youtube.com/watch?v={video_id}"
if meta.get("unavailable"):
title = f"_(unavailable — {meta.get('reason', 'unknown')})_"
channel = ""
uploaded = ""
duration = ""
views = ""
likes = ""
else:
title = meta.get("title") or curated.get("title") or ""
# Escape pipes in titles so table rendering doesn't break.
title = title.replace("|", "\\|")
channel = (meta.get("uploader") or "").replace("|", "\\|")
uploaded = fmt_date(meta.get("upload_date", ""))
duration = fmt_duration(meta.get("duration"))
views = fmt_int(meta.get("view_count"))
likes = fmt_int(meta.get("like_count"))
return f"| `{key}` | {title} | {channel} | {uploaded} | {duration} | {views} | {likes} | [watch]({url}) |"
def main() -> int:
with VIDEOS_JSON.open() as f:
data = json.load(f)
tense_entries = data.get("tenseGuides", {})
grammar_entries = data.get("grammarNotes", {})
# Collect all unique videoIds so we only call yt-dlp once per video
# (several grammar notes reuse tense-guide videos).
video_ids = {e["videoId"] for e in tense_entries.values()} | {
e["videoId"] for e in grammar_entries.values()
}
print(f"Fetching metadata for {len(video_ids)} unique videos…", file=sys.stderr)
metadata: dict[str, dict] = {}
with ThreadPoolExecutor(max_workers=8) as pool:
future_to_id = {pool.submit(fetch_metadata, vid): vid for vid in video_ids}
for future in as_completed(future_to_id):
vid = future_to_id[future]
metadata[vid] = future.result()
status = "" if metadata[vid].get("unavailable") else ""
print(f" {status} {vid}", file=sys.stderr)
missing_tenses = [tid for tid in EXPECTED_TENSE_IDS if tid not in tense_entries]
missing_grammar = [gid for gid in EXPECTED_GRAMMAR_IDS if gid not in grammar_entries]
today = date.today().isoformat()
lines: list[str] = []
lines.append("# Curated YouTube Videos")
lines.append("")
lines.append(
"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."
)
lines.append("")
lines.append(f"- Total tense-guide entries: **{len(tense_entries)}** of {len(EXPECTED_TENSE_IDS)}")
lines.append(f"- Total grammar-note entries: **{len(grammar_entries)}** of {len(EXPECTED_GRAMMAR_IDS)}")
lines.append(f"- Last verified: **{today}** (run `python3 Scripts/generate_videos_markdown.py` to refresh)")
lines.append("")
lines.append(
"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."
)
lines.append("")
# Tense guides section
lines.append("## Tense guides")
lines.append("")
lines.append("Tied to `TenseGuide.tenseId` in the Guide tab.")
lines.append("")
lines.append("| Tense ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |")
lines.append("|---|---|---|---|---|---|---|---|")
for tid in EXPECTED_TENSE_IDS:
if tid not in tense_entries:
continue
entry = tense_entries[tid]
lines.append(render_row(tid, entry, metadata.get(entry["videoId"], {})))
lines.append("")
# Grammar notes section
lines.append("## Grammar notes")
lines.append("")
lines.append("Tied to `GrammarNote.id` (hand-authored + generated) in the Guide → Grammar tab.")
lines.append("")
lines.append("| Grammar ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |")
lines.append("|---|---|---|---|---|---|---|---|")
for gid in EXPECTED_GRAMMAR_IDS:
if gid not in grammar_entries:
continue
entry = grammar_entries[gid]
lines.append(render_row(gid, entry, metadata.get(entry["videoId"], {})))
lines.append("")
# Missing section
if missing_tenses or missing_grammar:
lines.append("## Topics without a curated video")
lines.append("")
lines.append(
"These show a \"No video yet\" label in the app. Add entries to "
"`Conjuga/youtube_videos.json` to fill them in."
)
lines.append("")
if missing_tenses:
lines.append("**Tense guides:**")
lines.append("")
for tid in missing_tenses:
lines.append(f"- `{tid}`")
lines.append("")
if missing_grammar:
lines.append("**Grammar notes:**")
lines.append("")
for gid in missing_grammar:
lines.append(f"- `{gid}`")
lines.append("")
OUTPUT_MD.write_text("\n".join(lines))
print(f"\nWrote {OUTPUT_MD.relative_to(REPO_ROOT)}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,2 @@
in/
out/
+119
View File
@@ -0,0 +1,119 @@
# Guide enrichment plan
**Trigger**: WEIRDO was missing from the present-subjunctive guide. That's a perfect example of a deeper problem — most tense guides are surface-level reference cards (2-3 usages + examples), missing the mnemonics, contrast tables, and exception lists a real Spanish teacher would hand out.
**Goal**: bring every tense guide and grammar note up to "teacher-handout" depth — enough that a learner could study from it alone and pass a quiz.
## Current state (audit, 2026-05-11)
| Surface | Items | Source of truth | Typical body length | Verdict |
|---|---|---|---|---|
| Tense guides | 20 | `Conjuga/Conjuga/conjuga_data.json``tenseGuides[]` | 5001500 chars | **Shallow** — bare *Usages* + examples |
| Grammar notes | ~36 | `Conjuga/Conjuga/Models/GrammarNote.swift` (`GrammarNote.allNotes`, `generatedNotes`) | 15003000 chars | **Decent** — most have mnemonics and contrast examples |
| Reference store | — | `Conjuga/Conjuga/Services/ReferenceStore.swift` | varies | Not in scope for this pass |
Tense guides are the bulk of the work. Grammar notes need a smaller audit-and-fill pass.
## What "thorough" looks like
Every tense guide should include, at minimum:
1. **Quick TL;DR** — one sentence: what is this tense for?
2. **When to use it** — numbered usages, each with 2 contrast examples (a clear case and a borderline / common-mistake case).
3. **How to form it** — conjugation pattern for regular verbs (one table per AR/ER/IR if it differs), plus the irregular pattern callout if applicable. Cross-reference the conjugator screens if relevant.
4. **Common irregulars** — top 510 irregular verbs that learners will hit immediately in this tense (ser, estar, ir, tener, haber, dar, ver, decir, hacer, querer, poder, poner, saber, salir, traer, venir).
5. **Triggers / mnemonics** — words and structures that signal this tense. WEIRDO and ESCAPA for subjunctive; "yesterday / last X / specific time" for preterite; "used to / when I was a kid" for imperfect; etc.
6. **Pitfalls** — the top 35 mistakes English speakers make. e.g. preterite vs imperfect mixups, ir vs venir, ser vs estar overlap.
7. **Tense-vs-tense contrast** — pair with the closest neighbour and show 2 minimal pairs (preterite ↔ imperfect, present ↔ present-progressive, future ↔ ir-a + infinitive, subjunctive-presente ↔ subjunctive-imperfecto).
8. **Real-world feel** — 23 dialogue-style examples showing the tense in natural use, not just isolated sentences.
Every grammar note should include, at minimum:
1. The core distinction in one line.
2. Each side of the distinction with 46 clear examples covering different positions in a sentence.
3. A mnemonic if one is standard in the language (DOCTOR/PLACE, WEIRDO, ESCAPA, etc.).
4. Edge cases / verbs that change meaning (e.g. ser/estar adjectives, conocer/saber overlap).
5. A practice prompt: "Try translating these 3 sentences, then check below."
## Priority order
Triaged by learner impact (frequency of use × typical confusion):
**Tier 1 — most-used, most-confused** (do first):
1. `ind_presente` (Present indicative) — already 1324 chars, the longest tense guide. Audit for gaps; probably needs irregular tables.
2. `ind_preterito` (Preterite) — currently 492 chars, the shortest. **Highest priority** — every learner hits this and gets it wrong.
3. `ind_imperfecto` (Imperfect) — 774 chars. Always taught alongside preterite; the contrast is the entire game.
4. `subj_presente` (Present subjunctive) — ✅ done in this pass.
5. `imp_afirmativo` + `imp_negativo` (Imperative pair) — combined 2037 chars. Needs the tú/usted/nosotros/vosotros table and the negative-flips-to-subjunctive rule highlighted.
**Tier 2 — common but often skimped**:
6. `ind_futuro` (Simple future) — needs contrast with ir-a + infinitive (already covered in grammar notes; cross-link).
7. `cond_presente` (Conditional) — needs the "if-clause" patterns and the "softening request" usage ("¿Podrías…?").
8. `ind_perfecto` (Present perfect) — needs the haber + past participle conjugation table and the "ya / todavía / alguna vez" trigger words.
9. `subj_imperfecto_1` + `subj_imperfecto_2` (Past subjunctive -ra / -se) — needs the if-clause + condicional pairing.
**Tier 3 — compound and less-frequent** (still must be thorough):
10. `ind_pluscuamperfecto`, `ind_futuro_perfecto`, `ind_preterito_anterior` (literary)
11. `cond_perfecto`, `subj_perfecto`, `subj_pluscuamperfecto_1`, `subj_pluscuamperfecto_2`
12. `subj_futuro`, `subj_futuro_perfecto` (largely archaic — note they're rare but explain why they exist)
**Grammar notes audit**:
- Pass through all 36, score each on the "thorough" criteria above.
- Fill the gaps. Most already have mnemonics; some don't.
## Research sources
Cite explicitly in each draft so reviewers can verify. Order of trust:
1. **Real Academia Española (RAE) — Nueva gramática de la lengua española** — authoritative reference. Free online: `rae.es`.
2. **Studyspanish.com** and **SpanishDict.com** grammar references — best free per-topic explanations, well-curated example sentences.
3. **Practice Makes Perfect: Complete Spanish Grammar** (Dorothy Richmond, McGraw-Hill) — standard teaching reference. The PDF is already at the repo root for cross-reference.
4. **Lawless Spanish** (Laura Lawless) — accurate, concise, good on subjunctive nuances.
5. **The user's existing textbook***Complete Spanish Step-by-Step* (Bregstein) is already bundled. Cross-reference its chapter on each tense to keep voice consistent.
6. **YouTube — Butterfly Spanish (Ana), Spring Spanish, Dreaming Spanish (Pablo)** — for natural-use examples and the "feel" of when a native reaches for the tense. The repo already has a curated YouTube list at `Conjuga/Conjuga/youtube_videos.json` — pull from there when a topic has a matching video.
For mnemonics specifically: WEIRDO, ESCAPA, DOCTOR, PLACE are standard. Don't invent new ones unless we can't find a known one.
## Workflow per topic
This is what an enrichment "unit of work" looks like:
1. **Draft** — A research agent (Claude Code subagent, no API key, same pattern as the book translation pipeline) reads the current guide body, consults the sources listed above, drafts a new body following the "thorough" structure. Writes to `Conjuga/Scripts/guide-enrichment/drafts/<topicId>.md`.
2. **Self-review** — same agent re-reads its own draft against the checklist (TL;DR present? mnemonic present? contrast pair? top 3 pitfalls?). Notes anything it couldn't find a source for.
3. **Integrate** — a script reads the draft, swaps it into `conjuga_data.json` (for tense guides) or `GrammarNote.swift` (for grammar notes), bumps `courseDataVersion`, runs build to verify.
4. **Spot-check** — user opens the topic in the app on device, reads it, flags anything that feels wrong or missing.
5. **Commit** — one commit per topic, message: "Guide enrichment — <topic> (tier N)".
Batching: do tier-1 topics one at a time so the user can review and shape what "thorough enough" looks like. Tiers 2 and 3 can batch 35 topics per session once the format is dialed in.
## Tooling
Two small scripts will speed this up:
- **`enrich_topic.py <topicId>`** — opens the current body, writes a Markdown template at `drafts/<topicId>.md` with the section headers pre-filled, and prints a research prompt the user can hand to a subagent.
- **`apply_draft.py <topicId>`** — reads `drafts/<topicId>.md`, validates the section structure, swaps it into `conjuga_data.json` (or `GrammarNote.swift` for grammar notes), bumps `courseDataVersion`.
Build both when starting tier 1. Don't build them speculatively now.
## Effort estimate
- Tier 1 (5 topics): ~30 min research + 30 min draft + 15 min integrate = **~75 min per topic, ~6 hours total**.
- Tier 2 (4 topics): faster once the format is dialed in. ~45 min each, ~3 hours.
- Tier 3 (11 topics): ~30 min each (most are compound tenses with similar structure), ~5 hours.
- Grammar notes audit + fill: ~10 min audit each × 36 = 6 hours; ~30 min fill on the ~10 that need it = 5 hours. Total ~11 hours.
**Total scoped at ~25 hours.** Spread across sessions: maybe one tier-1 topic per session, two tier-2 or three tier-3 per session once the format's locked in.
## Ship plan
- Each commit is one topic enriched. Small, reviewable diffs.
- `courseDataVersion` bumps per commit so the change propagates on next launch.
- The user can preview new bodies via the in-app Guide tab without needing a redeploy after the commit hits gitea — they just need to rebuild + reinstall.
- The plan doc itself lives here so future sessions can pick up where this one left off without needing to re-derive the structure.
## Out of scope (intentional)
- Audio recordings of example sentences (could be a future TTS pre-bake).
- Per-region variants (Latin American vs Castilian usage notes) — flag when they matter (vosotros, leísmo), don't comprehensively document.
- Interactive exercises tied to each guide (separate Tests/Quiz infrastructure exists; cross-link instead of duplicate).
- Translation of the guides into Spanish (current guides are English-explanation, Spanish-examples; keep that asymmetry).
- A complete grammar-textbook rewrite. Stop at "depth a teacher would hand out as supplementary material."
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Apply enriched bodies from drafts/out/ back into the live source files.
Tense guides → Conjuga/Conjuga/conjuga_data.json (tenseGuides[].body)
Grammar notes → Conjuga/Conjuga/Models/GrammarNote.swift (body: \"\"\"...\"\"\")
Filename conventions in drafts/out/:
tense__<tenseId>.md — body to drop into the matching tenseGuide
note__<noteId>.md — body to drop into the matching GrammarNote(...) declaration
Run from anywhere; uses absolute paths anchored at the repo root.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO = Path('/Users/m4mini/Desktop/code/Spanish')
OUT_DIR = REPO / 'Conjuga/Scripts/guide-enrichment/out'
TENSE_JSON = REPO / 'Conjuga/Conjuga/conjuga_data.json'
NOTES_SWIFT = REPO / 'Conjuga/Conjuga/Models/GrammarNote.swift'
def read_draft(path: Path) -> str:
"""Drafts may start with comment blocks like `# Title: ...`, `# Category:
...`, and `# ENRICHED BODY` separated by blank lines. Strip every leading
line that is blank or starts with `#` until we reach the actual body."""
raw = path.read_text(encoding='utf-8')
lines = raw.splitlines()
start = 0
for i, line in enumerate(lines):
stripped = line.strip()
if stripped == '' or stripped.startswith('#'):
start = i + 1
continue
# Hit the first real content line — keep everything from here.
start = i
break
body = '\n'.join(lines[start:]).strip()
if not body:
raise ValueError(f'Empty body after stripping header in {path}')
return body
def apply_tense_guides() -> int:
data = json.loads(TENSE_JSON.read_text(encoding='utf-8'))
drafts = sorted(OUT_DIR.glob('tense__*.md'))
by_id = {g['tenseId']: g for g in data['tenseGuides']}
applied = 0
for path in drafts:
tense_id = path.stem.removeprefix('tense__')
if tense_id not in by_id:
print(f' SKIP {tense_id}: not in tenseGuides', file=sys.stderr)
continue
body = read_draft(path)
by_id[tense_id]['body'] = body
applied += 1
print(f' applied tense: {tense_id} ({len(body)} chars)')
TENSE_JSON.write_text(
json.dumps(data, ensure_ascii=False, separators=(',', ':')),
encoding='utf-8'
)
return applied
# Match each GrammarNote(...) declaration. Body uses """...""" — may contain
# anything except a triple-quote.
NOTE_PATTERN = re.compile(
r'(GrammarNote\(\s*id:\s*"([^"]+)",\s*'
r'title:\s*"(?:[^"\\]|\\.)*",\s*'
r'category:\s*"[^"]+",\s*'
r'body:\s*""")(.*?)(""")',
re.DOTALL
)
def apply_grammar_notes() -> int:
src = NOTES_SWIFT.read_text(encoding='utf-8')
drafts = sorted(OUT_DIR.glob('note__*.md'))
by_id = {p.stem.removeprefix('note__'): p for p in drafts}
applied = [0]
def replace_match(m):
prefix, note_id, _, suffix = m.group(1), m.group(2), m.group(3), m.group(4) if m.lastindex and m.lastindex >= 4 else m.group(3)
return m.group(0) # placeholder, see real callback below
def real_replace(m):
prefix = m.group(1)
note_id = m.group(2)
suffix = m.group(4)
if note_id not in by_id:
return m.group(0)
body = read_draft(by_id[note_id])
if '"""' in body:
raise ValueError(f'Body for {note_id} contains triple-quote — would break Swift parser')
# Re-indent to match the existing Swift block. The existing format uses
# 8 spaces of leading indent inside body lines. We don't enforce that —
# the Swift compiler handles multiline string indentation by stripping
# the leading whitespace common to all lines based on the closing """.
# Just write the body verbatim.
applied[0] += 1
print(f' applied note: {note_id} ({len(body)} chars)')
return f'{prefix}\n{body}\n{suffix}'
new_src = NOTE_PATTERN.sub(real_replace, src)
NOTES_SWIFT.write_text(new_src, encoding='utf-8')
return applied[0]
def main():
if not OUT_DIR.exists():
print(f'No drafts/out directory at {OUT_DIR}', file=sys.stderr)
sys.exit(1)
print(f'=== Tense guides ===')
tense_count = apply_tense_guides()
print(f'\n=== Grammar notes ===')
note_count = apply_grammar_notes()
print(f'\nTotal applied: {tense_count} tense guides + {note_count} grammar notes')
if tense_count == 0 and note_count == 0:
print('Nothing applied — drafts/out/ was empty.', file=sys.stderr)
sys.exit(2)
if __name__ == '__main__':
main()
+13 -2
View File
@@ -46,6 +46,9 @@ SPANISH_ARTICLES = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
ENGLISH_STARTERS = {"the", "a", "an", "to", "my", "his", "her", "our", "their"} ENGLISH_STARTERS = {"the", "a", "an", "to", "my", "his", "her", "our", "their"}
HABER_FORMS = {"he", "has", "ha", "hemos", "habéis", "han"}
def language_score(s: str) -> "tuple[int, int]": def language_score(s: str) -> "tuple[int, int]":
"""Return (es_score, en_score) for a string.""" """Return (es_score, en_score) for a string."""
es = 0 es = 0
@@ -56,9 +59,17 @@ def language_score(s: str) -> "tuple[int, int]":
if not words: if not words:
return (es, en) return (es, en)
first = words[0].strip(",.;:") first = words[0].strip(",.;:")
if first in SPANISH_ARTICLES: second = words[1].strip(",.;:") if len(words) > 1 else ""
# Spanish present-perfect ("he tenido", "Ha andado") starts with a haber
# form followed by an -ado/-ido past participle. Recognise this pattern
# before the bare-pronoun check so "he" isn't mistaken for English "he".
if first in HABER_FORMS and (
second.endswith(("ado", "ido", "to", "cho", "sto", "esto"))
):
es += 3
elif first in SPANISH_ARTICLES:
es += 2 es += 2
if first in ENGLISH_STARTERS: elif first in ENGLISH_STARTERS:
en += 2 en += 2
# Spanish-likely endings on later words # Spanish-likely endings on later words
for w in words: for w in words:
@@ -33,7 +33,8 @@ CHAPTERS_JSON = HERE / "chapters.json"
ANSWERS_JSON = HERE / "answers.json" ANSWERS_JSON = HERE / "answers.json"
OCR_JSON = HERE / "ocr.json" OCR_JSON = HERE / "ocr.json"
PDF_OCR_JSON = HERE / "pdf_ocr.json" PDF_OCR_JSON = HERE / "pdf_ocr.json"
PAIRED_VOCAB_JSON = HERE / "paired_vocab.json" # bounding-box pairs (preferred) PAIRED_VOCAB_JSON = HERE / "paired_vocab.json" # bounding-box pairs (fallback)
PAIRED_VOCAB_LLM_JSON = HERE / "paired_vocab_llm.json" # LLM vision pairs (preferred)
OUT_BOOK = HERE / "book.json" OUT_BOOK = HERE / "book.json"
OUT_VOCAB = HERE / "vocab_cards.json" OUT_VOCAB = HERE / "vocab_cards.json"
@@ -224,8 +225,10 @@ def main() -> None:
pdf_ocr_raw = load(PDF_OCR_JSON) if PDF_OCR_JSON.exists() else {} pdf_ocr_raw = load(PDF_OCR_JSON) if PDF_OCR_JSON.exists() else {}
pdf_pages = build_pdf_page_index(pdf_ocr_raw) if pdf_ocr_raw else {} pdf_pages = build_pdf_page_index(pdf_ocr_raw) if pdf_ocr_raw else {}
paired_vocab = load(PAIRED_VOCAB_JSON) if PAIRED_VOCAB_JSON.exists() else {} paired_vocab = load(PAIRED_VOCAB_JSON) if PAIRED_VOCAB_JSON.exists() else {}
paired_llm = load(PAIRED_VOCAB_LLM_JSON) if PAIRED_VOCAB_LLM_JSON.exists() else {}
print(f"Mapped {len(pdf_pages)} PDF pages to book page numbers") print(f"Mapped {len(pdf_pages)} PDF pages to book page numbers")
print(f"Loaded bounding-box pairs for {len(paired_vocab)} vocab images") print(f"Loaded bounding-box pairs for {len(paired_vocab)} vocab images")
print(f"Loaded LLM-vision pairs for {len(paired_llm)} vocab images")
# Build a global set of EPUB narrative lines (for subtraction when pulling vocab) # Build a global set of EPUB narrative lines (for subtraction when pulling vocab)
narrative_set = set() narrative_set = set()
@@ -282,28 +285,60 @@ def main() -> None:
if repairs > 0: if repairs > 0:
merged_pages += 1 merged_pages += 1
# Prefer bounding-box pairs (from paired_vocab.json) when # Source priority:
# present. Fall back to the block-alternation heuristic. # 1) LLM-vision pairs (paired_vocab_llm.json) — semantic
# classification (pair_table / reference_only / hybrid)
# with correct orientation.
# 2) Bounding-box pairs (paired_vocab.json) — Vision OCR
# with X-gap row splitting.
# 3) Block-alternation heuristic — flat OCR fallback.
llm_entry = paired_llm.get(src, {}) if isinstance(paired_llm.get(src), dict) else {}
llm_kind = llm_entry.get("kind")
llm_pairs = llm_entry.get("pairs", []) if llm_entry else []
bbox = paired_vocab.get(src, {}) bbox = paired_vocab.get(src, {})
bbox_pairs = bbox.get("pairs", []) if isinstance(bbox, dict) else [] bbox_pairs = bbox.get("pairs", []) if isinstance(bbox, dict) else []
heuristic = build_vocab_cards_for_block( heuristic = build_vocab_cards_for_block(
{"src": src}, {"src": src},
{"lines": merged_lines, "confidence": merged_conf}, {"lines": merged_lines, "confidence": merged_conf},
ch, current_section_title, bi ch, current_section_title, bi
) )
if bbox_pairs: # Choose pair source. For reference_only (Spanish-only tables)
# we deliberately produce no cards — the UI will fall back to
# rendering the flat OCR lines as a reference list. Same for
# hybrid images where the LLM determined no genuine pair rows
# exist (e.g. estar conjugations with English glosses on the
# header row only).
if llm_kind == "reference_only" or (llm_kind == "hybrid" and not llm_pairs):
cards_for_block = []
pair_source = "llm-no-pairs"
elif llm_pairs:
cards_for_block = [
{"front": p["es"], "back": p["en"]}
for p in llm_pairs
if p.get("es") and p.get("en")
]
for c in cards_for_block:
all_vocab_cards.append({
"front": c["front"], "back": c["back"],
"chapter": ch["number"],
"chapterTitle": ch["title"],
"section": current_section_title,
"sourceImage": src,
})
pair_source = "llm-" + (llm_kind or "pairs")
elif bbox_pairs:
cards_for_block = [ cards_for_block = [
{"front": p["es"], "back": p["en"]} {"front": p["es"], "back": p["en"]}
for p in bbox_pairs for p in bbox_pairs
if p.get("es") and p.get("en") if p.get("es") and p.get("en")
] ]
# Also feed the flashcard deck
for p in bbox_pairs: for p in bbox_pairs:
if p.get("es") and p.get("en"): if p.get("es") and p.get("en"):
all_vocab_cards.append({ all_vocab_cards.append({
"front": p["es"], "front": p["es"], "back": p["en"],
"back": p["en"],
"chapter": ch["number"], "chapter": ch["number"],
"chapterTitle": ch["title"], "chapterTitle": ch["title"],
"section": current_section_title, "section": current_section_title,
@@ -326,6 +361,7 @@ def main() -> None:
"source": pair_source, "source": pair_source,
"bookPage": book_page, "bookPage": book_page,
"repairs": repairs, "repairs": repairs,
"tableKind": llm_kind,
}) })
continue continue
@@ -0,0 +1,58 @@
import Foundation
import SwiftData
/// A long-form bilingual book bundled with the app. Chapter content lives in
/// `BookChapter` rows; this model carries the per-book metadata.
@Model
public final class Book {
@Attribute(.unique) public var id: String = "" // matches `slug`
public var slug: String = ""
public var title: String = ""
public var author: String = ""
public var language: String = ""
public var chapterCount: Int = 0
public var accentColorHex: String = ""
/// JSON-encoded `[String: WordGloss]` the book reader's primary word
/// lookup, keyed by the cleaned (lowercased, punctuation-trimmed) word.
/// Pre-computed at import time so taps resolve instantly and in context.
public var glossaryJSON: Data = Data()
public init(
slug: String,
title: String,
author: String,
language: String,
chapterCount: Int,
accentColorHex: String,
glossaryJSON: Data = Data()
) {
self.id = slug
self.slug = slug
self.title = title
self.author = author
self.language = language
self.chapterCount = chapterCount
self.accentColorHex = accentColorHex
self.glossaryJSON = glossaryJSON
}
/// The decoded per-book glossary. Decode once and cache at the call site
/// this re-decodes on every call.
public func glossary() -> [String: WordGloss] {
(try? JSONDecoder().decode([String: WordGloss].self, from: glossaryJSON)) ?? [:]
}
}
/// One glossary entry: a word's dictionary base form, English meaning, and
/// part of speech, translated in the book's context at import time.
public struct WordGloss: Codable, Hashable, Sendable {
public let baseForm: String
public let english: String
public let partOfSpeech: String
public init(baseForm: String, english: String, partOfSpeech: String) {
self.baseForm = baseForm
self.english = english
self.partOfSpeech = partOfSpeech
}
}
@@ -0,0 +1,44 @@
import Foundation
import SwiftData
/// One chapter of a `Book`. Spanish + English paragraphs are stored as JSON-
/// encoded `[String]` so SwiftData doesn't have to manage variable-length
/// arrays directly.
@Model
public final class BookChapter {
@Attribute(.unique) public var id: String = "" // "<bookSlug>-ch<number>"
public var bookSlug: String = ""
public var number: Int = 0
public var title: String = ""
/// Spanish paragraph count, stored at seed time so chapter lists can show
/// it without decoding the full `paragraphsESJSON` blob on every render.
public var paragraphCount: Int = 0
public var paragraphsESJSON: Data = Data()
public var paragraphsENJSON: Data = Data()
public init(
id: String,
bookSlug: String,
number: Int,
title: String,
paragraphCount: Int,
paragraphsESJSON: Data,
paragraphsENJSON: Data
) {
self.id = id
self.bookSlug = bookSlug
self.number = number
self.title = title
self.paragraphCount = paragraphCount
self.paragraphsESJSON = paragraphsESJSON
self.paragraphsENJSON = paragraphsENJSON
}
public func paragraphsES() -> [String] {
(try? JSONDecoder().decode([String].self, from: paragraphsESJSON)) ?? []
}
public func paragraphsEN() -> [String] {
(try? JSONDecoder().decode([String].self, from: paragraphsENJSON)) ?? []
}
}
@@ -0,0 +1,25 @@
import Foundation
import SwiftData
/// Persistent record of a YouTube video downloaded to the device (Issue #21).
/// Files live in the app's documents directory under `videos/<videoId>.mp4`;
/// this model tracks the metadata needed to locate, display, and manage them.
///
/// Lives in the local store, not CloudKit downloads are per-device.
@Model
public final class DownloadedVideo {
/// YouTube video ID the primary key (unique).
@Attribute(.unique) public var videoId: String = ""
public var title: String = ""
public var filename: String = ""
public var byteCount: Int = 0
public var downloadedAt: Date = Date()
public init(videoId: String, title: String, filename: String, byteCount: Int) {
self.videoId = videoId
self.title = title
self.filename = filename
self.byteCount = byteCount
self.downloadedAt = Date()
}
}
@@ -0,0 +1,44 @@
import Foundation
import SwiftData
/// User mark on a vocab card for "extra study" focus. Cloud-synced so the set
/// follows the user across devices. Denormalises card identity (deckId/front/
/// back) so the Course tab can resolve a marked-cards view without joining
/// against the local-only `VocabCard` store.
///
/// CloudKit forbids `@Attribute(.unique)`, so callers must fetch-or-create
/// by `id` to maintain uniqueness in code.
@Model
public final class ExtraStudyMark {
/// Stable hash of (deckId, front, back, examplesES, examplesEN). Same
/// shape as `CourseCardStore.reviewKey(for:)` so a mark and a review
/// card describe the same logical card.
public var id: String = ""
public var deckId: String = ""
public var courseName: String = ""
public var weekNumber: Int = 0
public var front: String = ""
public var back: String = ""
public var markedAt: Date = Date()
public init(
id: String,
deckId: String,
courseName: String,
weekNumber: Int,
front: String,
back: String,
markedAt: Date = Date()
) {
self.id = id
self.deckId = deckId
self.courseName = courseName
self.weekNumber = weekNumber
self.front = front
self.back = back
self.markedAt = markedAt
}
}
@@ -0,0 +1,31 @@
import Foundation
/// Decides whether a (verb, tense) combo is eligible for the Full Table
/// practice mode. Pulled out of `PracticeSessionService` so the rule can be
/// unit tested in isolation.
public enum FullTableEligibility {
/// `VerbForm.regularity` values understood by the data set.
public enum Regularity: String, Sendable {
case regular // paradigm-teaching verbs (small curated set)
case ordinary // pattern-following verbs (the bulk of the dataset)
case irregular
case orto // orthographic spelling change (e.g. busqué)
}
/// Returns true when the given (verb, tense) combo is a candidate for
/// Full Table i.e. it follows the regular pattern with no irregularity
/// and no orthographic spelling change.
///
/// The conjugation must be present in all 6 person slots; missing forms
/// disqualify the combo.
///
/// Accepted: `regular` (paradigm-teaching verbs) and `ordinary`
/// (pattern-following verbs `hablar`, `comer`, `vivir`, etc.).
/// Rejected: `irregular` and `orto` (orthographic spelling changes).
public static func isFullyRegular(regularities: [String]) -> Bool {
guard regularities.count == 6 else { return false }
let acceptable: Set<String> = ["regular", "ordinary"]
return regularities.allSatisfy { acceptable.contains($0) }
}
}
@@ -0,0 +1,81 @@
import Foundation
/// Pure practice-pool filtering (Issue #26).
///
/// Takes plain value snapshots of the verb + irregular-span data and computes
/// the set of verb IDs eligible for practice under the user's selected filters.
/// Deliberately decoupled from SwiftData so the same logic is directly testable
/// without a ModelContainer.
public enum PracticeFilter {
/// Minimal verb snapshot for filtering.
public struct VerbSlot: Sendable, Hashable {
public let id: Int
public let level: String
public init(id: Int, level: String) {
self.id = id
self.level = level
}
}
/// Minimal irregular-span snapshot for filtering.
public struct IrregularSlot: Sendable, Hashable {
public let verbId: Int
public let category: IrregularSpan.SpanCategory
public init(verbId: Int, category: IrregularSpan.SpanCategory) {
self.verbId = verbId
self.category = category
}
}
/// Union of `VerbLevelGroup.dataLevels(for:)` across every user-facing level.
/// An empty input produces an empty result; callers decide the empty semantics.
public static func dataLevels(forSelectedLevels levels: Set<String>) -> Set<String> {
levels.reduce(into: Set<String>()) { acc, level in
acc.formUnion(VerbLevelGroup.dataLevels(for: level))
}
}
/// Verb IDs whose `level` falls inside any of the selected level groups.
public static func verbIDs(
matchingLevels selectedLevels: Set<String>,
in verbs: [VerbSlot]
) -> Set<Int> {
guard !selectedLevels.isEmpty else { return [] }
let expanded = dataLevels(forSelectedLevels: selectedLevels)
return Set(verbs.filter { expanded.contains($0.level) }.map(\.id))
}
/// Verb IDs that have at least one irregular span in the requested categories.
/// Returns an empty set when `categories` is empty caller decides whether
/// that means "no constraint" or "no matches".
public static func verbIDs(
matchingIrregularCategories categories: Set<IrregularSpan.SpanCategory>,
in spans: [IrregularSlot]
) -> Set<Int> {
guard !categories.isEmpty else { return [] }
var ids = Set<Int>()
for slot in spans where categories.contains(slot.category) {
ids.insert(slot.verbId)
}
return ids
}
/// Practice pool: verbs at the selected levels, intersected with irregular
/// categories when that filter is active.
///
/// Semantics (Issue #26):
/// - `selectedLevels` empty empty pool (literal).
/// - `irregularCategories` empty no irregular constraint (all verbs at level).
public static func allowedVerbIDs(
verbs: [VerbSlot],
spans: [IrregularSlot],
selectedLevels: Set<String>,
irregularCategories: Set<IrregularSpan.SpanCategory>
) -> Set<Int> {
let levelIDs = verbIDs(matchingLevels: selectedLevels, in: verbs)
guard !irregularCategories.isEmpty else { return levelIDs }
let irregularIDs = verbIDs(matchingIrregularCategories: irregularCategories, in: spans)
return levelIDs.intersection(irregularIDs)
}
}
@@ -0,0 +1,23 @@
import Foundation
/// A single entry from the curated "100 most common reflexive verbs" list
/// (Gitea issue #28). Sourced from spanishwithdaniel.com.
///
/// `baseInfinitive` is the stem without the reflexive "-se" suffix, used to
/// match this entry to the app's Verb records (which store bare infinitives).
/// `usageHint` captures trailing prepositions or set-phrase completions e.g.,
/// "a" for `acercarse a`, "de acuerdo" for `ponerse de acuerdo`. Nil when the
/// reflexive form has no commonly paired preposition.
public struct ReflexiveVerb: Codable, Hashable, Sendable {
public let infinitive: String
public let baseInfinitive: String
public let english: String
public let usageHint: String?
public init(infinitive: String, baseInfinitive: String, english: String, usageHint: String? = nil) {
self.infinitive = infinitive
self.baseInfinitive = baseInfinitive
self.english = english
self.usageHint = usageHint
}
}
@@ -23,4 +23,22 @@ public enum SharedStore {
/// and hit the exact container used for seeding. /// and hit the exact container used for seeding.
@MainActor @MainActor
public static var localContainer: ModelContainer? public static var localContainer: ModelContainer?
/// The canonical model list for the local reference-data store.
///
/// The main app AND the widget extension MUST build their `ModelContainer`
/// from this exact set. Opening the store with a *subset* schema makes
/// SwiftData destructively migrate it silently dropping every table that
/// isn't listed. That is how widget refreshes repeatedly wiped the bundled
/// `Book`/`BookChapter` rows (and `TextbookChapter` before them). Keeping
/// one shared list means a newly-added model can't be forgotten in the
/// widget and quietly nuke its own data.
public static var localSchemaModels: [any PersistentModel.Type] {
[
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self, DownloadedVideo.self,
Book.self, BookChapter.self,
]
}
} }
@@ -32,12 +32,23 @@ public struct WordAnnotation: Codable, Identifiable, Hashable {
public let baseForm: String public let baseForm: String
public let english: String public let english: String
public let partOfSpeech: String public let partOfSpeech: String
/// Human-readable name of the resource that produced this definition
/// (e.g. "Book glossary", "Dictionary", "AI guess"). Defaulted so older
/// persisted annotations without the field still decode.
public var source: String = ""
public init(word: String, baseForm: String, english: String, partOfSpeech: String) { public init(
word: String,
baseForm: String,
english: String,
partOfSpeech: String,
source: String = ""
) {
self.word = word self.word = word
self.baseForm = baseForm self.baseForm = baseForm
self.english = english self.english = english
self.partOfSpeech = partOfSpeech self.partOfSpeech = partOfSpeech
self.source = source
} }
} }
@@ -0,0 +1,17 @@
import Foundation
/// A single Spanish / English example sentence pair for a verb at a specific tense.
/// Used by the Verb detail view (Issue #27). Generated at runtime via Foundation
/// Models and cached to disk; shape is intentionally simple Codable for easy
/// JSON persistence and cross-module sharing.
public struct VerbExample: Codable, Hashable, Sendable {
public let tenseId: String
public let spanish: String
public let english: String
public init(tenseId: String, spanish: String, english: String) {
self.tenseId = tenseId
self.spanish = spanish
self.english = english
}
}
@@ -0,0 +1,10 @@
import Foundation
public extension VerbLevel {
/// The highest-ranked `VerbLevel` in `set` per `allCases` ordering.
/// Used when a single representative level is required (word-of-day
/// widget, AI chat/story scenario generation).
static func highest(in set: Set<VerbLevel>) -> VerbLevel? {
allCases.last { set.contains($0) }
}
}
@@ -0,0 +1,74 @@
import Testing
@testable import SharedModels
@Suite("FullTableEligibility")
struct FullTableEligibilityTests {
// MARK: - Should be eligible
@Test("all-ordinary verb (e.g. hablar present) is eligible")
func allOrdinary() {
// hablar present: hablo / hablas / habla / hablamos / habláis / hablan
let r = Array(repeating: "ordinary", count: 6)
#expect(FullTableEligibility.isFullyRegular(regularities: r))
}
@Test("all-regular paradigm verb (e.g. vivir present) is eligible")
func allRegular() {
// vivir present is tagged "regular" in the curated subset
let r = Array(repeating: "regular", count: 6)
#expect(FullTableEligibility.isFullyRegular(regularities: r))
}
@Test("mixed regular + ordinary across persons is eligible")
func mixedRegularOrdinary() {
// The data never actually mixes these, but it shouldn't matter if it
// did both labels mean "follows the regular pattern".
let r = ["regular", "ordinary", "regular", "ordinary", "regular", "ordinary"]
#expect(FullTableEligibility.isFullyRegular(regularities: r))
}
// MARK: - Should NOT be eligible
@Test("any irregular form rejects the combo")
func anyIrregular() {
var r = Array(repeating: "ordinary", count: 6)
r[3] = "irregular"
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
}
@Test("orthographic spelling change rejects the combo (excluded by design)")
func anyOrto() {
// buscar preterite: yo "busqué" carries an orto tag for the cqu shift.
// Per design choice, orto verbs are NOT eligible for Full Table.
var r = Array(repeating: "ordinary", count: 6)
r[0] = "orto"
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
}
@Test("all-irregular rejects")
func allIrregular() {
let r = Array(repeating: "irregular", count: 6)
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
}
// MARK: - Edge cases
@Test("incomplete forms (fewer than 6 persons) are rejected")
func incompleteForms() {
let r = Array(repeating: "ordinary", count: 5)
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
}
@Test("empty input is rejected")
func empty() {
#expect(!FullTableEligibility.isFullyRegular(regularities: []))
}
@Test("unknown regularity value is rejected (defensive default)")
func unknownValue() {
var r = Array(repeating: "ordinary", count: 6)
r[2] = "garbage_value"
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
}
}
@@ -0,0 +1,212 @@
import Testing
@testable import SharedModels
// Practice pool = selected levels selected tenses selected irregular types
// (Issue #26). This suite covers the level + irregular intersection; tense
// filtering is a separate concern handled at the review-card layer.
@Suite("PracticeFilter — level selection")
struct PracticeFilterLevelTests {
@Test("single level expands to that group's data-levels")
func singleLevelExpansion() {
let expected = VerbLevelGroup.dataLevels(for: "elementary")
#expect(PracticeFilter.dataLevels(forSelectedLevels: ["elementary"]) == expected)
}
@Test("multi-level union merges every group's data-levels")
func multiLevelUnion() {
let union = PracticeFilter.dataLevels(forSelectedLevels: ["elementary", "intermediate"])
#expect(union.isSuperset(of: VerbLevelGroup.dataLevels(for: "elementary")))
#expect(union.isSuperset(of: VerbLevelGroup.dataLevels(for: "intermediate")))
}
@Test("empty selected-levels produces empty data-levels")
func emptyLevelsProducesEmpty() {
#expect(PracticeFilter.dataLevels(forSelectedLevels: []).isEmpty)
}
@Test("unknown level passes through to its raw value")
func unknownLevelPassthrough() {
// Preserves VerbLevelGroup's fallback contract.
#expect(PracticeFilter.dataLevels(forSelectedLevels: ["custom"]) == ["custom"])
}
}
@Suite("PracticeFilter — verb IDs by level")
struct PracticeFilterVerbLevelTests {
// Fixtures: four verbs spanning basic + elementary subgroups + intermediate.
private let verbs: [PracticeFilter.VerbSlot] = [
.init(id: 1, level: "basic"),
.init(id: 2, level: "elementary"),
.init(id: 3, level: "elementary_2"),
.init(id: 4, level: "intermediate_1"),
]
@Test("elementary selection matches elementary base and subgroup levels")
func elementarySubgroupMatch() {
let ids = PracticeFilter.verbIDs(matchingLevels: ["elementary"], in: verbs)
#expect(ids == [2, 3])
}
@Test("multi-level selection unions matching verb IDs")
func multiLevelUnionIds() {
let ids = PracticeFilter.verbIDs(matchingLevels: ["basic", "intermediate"], in: verbs)
#expect(ids == [1, 4])
}
@Test("empty level selection returns no verbs (literal semantics)")
func emptySelectionReturnsEmpty() {
let ids = PracticeFilter.verbIDs(matchingLevels: [], in: verbs)
#expect(ids.isEmpty)
}
}
@Suite("PracticeFilter — verb IDs by irregular category")
struct PracticeFilterIrregularTests {
// Verb 10: regular (no spans).
// Verb 20: one spelling-change span.
// Verb 30: stem-change + unique-irregular spans.
private let spans: [PracticeFilter.IrregularSlot] = [
.init(verbId: 20, category: .spelling),
.init(verbId: 30, category: .stemChange),
.init(verbId: 30, category: .uniqueIrregular),
]
@Test("empty category set returns empty — caller decides the semantics")
func emptyCategoriesReturnsEmpty() {
let ids = PracticeFilter.verbIDs(matchingIrregularCategories: [], in: spans)
#expect(ids.isEmpty)
}
@Test("single category picks matching verbs only")
func singleCategoryMatch() {
let ids = PracticeFilter.verbIDs(matchingIrregularCategories: [.spelling], in: spans)
#expect(ids == [20])
}
@Test("multiple categories union their matches")
func multipleCategoriesUnion() {
let ids = PracticeFilter.verbIDs(
matchingIrregularCategories: [.spelling, .stemChange],
in: spans
)
#expect(ids == [20, 30])
}
@Test("a verb with multiple matching spans is returned once")
func verbWithMultipleSpansDeduped() {
let ids = PracticeFilter.verbIDs(
matchingIrregularCategories: [.stemChange, .uniqueIrregular],
in: spans
)
#expect(ids == [30])
}
}
@Suite("PracticeFilter — allowedVerbIDs (levels ∩ irregulars)")
struct PracticeFilterIntersectionTests {
// Realistic fixture:
// #1 basic, regular
// #2 basic, spelling-change
// #3 elementary, spelling-change
// #4 elementary, stem-change
// #5 intermediate, unique-irregular
private let verbs: [PracticeFilter.VerbSlot] = [
.init(id: 1, level: "basic"),
.init(id: 2, level: "basic"),
.init(id: 3, level: "elementary"),
.init(id: 4, level: "elementary_1"),
.init(id: 5, level: "intermediate"),
]
private let spans: [PracticeFilter.IrregularSlot] = [
.init(verbId: 2, category: .spelling),
.init(verbId: 3, category: .spelling),
.init(verbId: 4, category: .stemChange),
.init(verbId: 5, category: .uniqueIrregular),
]
@Test("no irregular filter keeps every verb at the selected level")
func noIrregularConstraint() {
let ids = PracticeFilter.allowedVerbIDs(
verbs: verbs,
spans: spans,
selectedLevels: ["basic"],
irregularCategories: []
)
#expect(ids == [1, 2])
}
@Test("Issue #26 worked example: beginner + spelling-change → only #2")
func issueWorkedExample() {
let ids = PracticeFilter.allowedVerbIDs(
verbs: verbs,
spans: spans,
selectedLevels: ["basic"],
irregularCategories: [.spelling]
)
#expect(ids == [2])
}
@Test("filter is an intersection, not a union: level-mismatched spans are excluded")
func intersectionExcludesOtherLevels() {
// Elementary has a spelling-change verb (#3). Selecting basic + spelling
// must NOT leak #3 through the irregular filter alone.
let ids = PracticeFilter.allowedVerbIDs(
verbs: verbs,
spans: spans,
selectedLevels: ["basic"],
irregularCategories: [.spelling]
)
#expect(!ids.contains(3))
}
@Test("empty level selection produces empty pool regardless of irregular filter")
func emptyLevelsLocksOutPractice() {
let ids = PracticeFilter.allowedVerbIDs(
verbs: verbs,
spans: spans,
selectedLevels: [],
irregularCategories: [.spelling]
)
#expect(ids.isEmpty)
}
@Test("multi-level + multi-category pulls every matching pair")
func multiLevelMultiCategory() {
let ids = PracticeFilter.allowedVerbIDs(
verbs: verbs,
spans: spans,
selectedLevels: ["elementary", "intermediate"],
irregularCategories: [.stemChange, .uniqueIrregular]
)
#expect(ids == [4, 5])
}
}
@Suite("VerbLevel.highest")
struct VerbLevelHighestTests {
@Test("returns the highest-ranked level in the set")
func highestOfMany() {
#expect(VerbLevel.highest(in: [.basic, .intermediate, .elementary]) == .intermediate)
}
@Test("returns the sole element when set is a singleton")
func singleton() {
#expect(VerbLevel.highest(in: [.advanced]) == .advanced)
}
@Test("returns nil for the empty set")
func empty() {
#expect(VerbLevel.highest(in: []) == nil)
}
@Test("ranks expert above advanced above intermediate above elementary above basic")
func fullRanking() {
#expect(VerbLevel.highest(in: [.basic, .elementary, .intermediate, .advanced, .expert]) == .expert)
}
}
+12
View File
@@ -28,6 +28,9 @@ schemes:
packages: packages:
SharedModels: SharedModels:
path: SharedModels path: SharedModels
YouTubeKit:
url: https://github.com/alexeichhorn/YouTubeKit.git
from: 0.3.0
settings: settings:
base: base:
@@ -47,6 +50,14 @@ targets:
buildPhase: resources buildPhase: resources
- path: Conjuga/course_data.json - path: Conjuga/course_data.json
buildPhase: resources buildPhase: resources
- path: Conjuga/reflexive_verbs.json
buildPhase: resources
- path: Conjuga/youtube_videos.json
buildPhase: resources
- path: Conjuga/textbook_data.json
buildPhase: resources
- path: Conjuga/textbook_vocab.json
buildPhase: resources
info: info:
path: Conjuga/Info.plist path: Conjuga/Info.plist
properties: properties:
@@ -78,6 +89,7 @@ targets:
dependencies: dependencies:
- target: ConjugaWidgetExtension - target: ConjugaWidgetExtension
- package: SharedModels - package: SharedModels
- package: YouTubeKit
ConjugaWidgetExtension: ConjugaWidgetExtension:
type: app-extension type: app-extension
+13 -7
View File
@@ -9,15 +9,18 @@ A Spanish-learning iOS app that combines verb conjugation practice, a full textb
- **Focus modes** — weak verbs (SM-2 spaced repetition), irregularity drills (spelling / stem / unique), common tenses - **Focus modes** — weak verbs (SM-2 spaced repetition), irregularity drills (spelling / stem / unique), common tenses
- **1,750 verbs** across 5 levels (Basic → Expert) with 209 K pre-conjugated forms and 23 K irregular-span annotations - **1,750 verbs** across 5 levels (Basic → Expert) with 209 K pre-conjugated forms and 23 K irregular-span annotations
- **20 tenses** — every indicative, subjunctive, conditional, and imperative tense, each with character-level irregular highlighting - **20 tenses** — every indicative, subjunctive, conditional, and imperative tense, each with character-level irregular highlighting
- **Irregularity filter** — search the verb list by Any Irregular / Spelling Change / Stem Change / Unique Irregular, combinable with level filter - **Verb list filters** — search by level, by irregularity (Any / Spelling Change / Stem Change / Unique Irregular), or by reflexive only (curated 100-verb list); filters compose
- **Practice pool filters** (Settings) — multi-select per level, per tense, per irregular-type, plus a "reflexive verbs only" toggle. Practice pool = intersection of all four.
- **Verb detail pages** — conjugation table, six AI-generated example sentences (one per core tense), and reflexive infinitive + preposition hint for curated reflexive verbs
- **Text-to-speech** on any form - **Text-to-speech** on any form
### Content & study ### Content & study
- **Textbook reader** — 30 chapters of *Complete Spanish Step-by-Step* with 251 interactive exercises (keyboard + Apple Pencil), 931 OCR'd vocab tables rendered as Spanish→English grids (~3 100 paired cards extracted via bounding-box OCR) - **Textbook reader** — 30 chapters of *Complete Spanish Step-by-Step* with 251 interactive exercises (keyboard + Apple Pencil), 931 OCR'd vocab tables rendered as Spanish→English grids (~3 100 paired cards extracted via bounding-box OCR)
- **Course decks** — weekly vocab decks with example sentences, week tests, and cumulative checkpoint exams - **Course decks** — weekly vocab decks with example sentences, week tests, and cumulative checkpoint exams
- **Stem-change toggle** on Week 4 flashcard decks (E-IE, E-I, O-UE, U-UE) showing inline present-tense conjugations - **Stem-change toggle** on Week 4 flashcard decks (E-IE, E-I, O-UE, U-UE) showing inline present-tense conjugations
- **Grammar guide** — 20 tense guides with usage rules and examples + 20+ grammar topic notes (ser/estar, por/para, preterite/imperfect, etc.), each with 100+ practice exercises - **Grammar guide** — 20 tense guides with usage rules and examples + 36 grammar topic notes (ser/estar, por/para, preterite/imperfect, reflexives, subjunctive triggers, etc.), each with 100+ practice exercises
- **Grammar exercises** — interactive quizzes for 5 core topics (ser/estar, por/para, preterite/imperfect, subjunctive, personal *a*) - **Grammar exercises** — interactive quizzes for 5 core topics (ser/estar, por/para, preterite/imperfect, subjunctive, personal *a*)
- **Curated YouTube videos** — 54 hand-picked videos attached to guide and grammar items (preference for The Language Tutor's numbered lessons + BaseLang). Each item offers three actions: **Stream** (opens YouTube app / Safari), **Download** (YouTubeKit extraction + AVFoundation mux for modern adaptive-stream videos), **Play** (full-screen AVPlayer from local MP4). Settings → Downloaded Videos lists all downloads with total size, per-item delete, and a 500 MB warning.
### AI & speech ### AI & speech
- **Conversational practice** — on-device AI chat partner (Apple Foundation Models) with 10 scenario types; chat bubbles have tappable words that open dictionary / on-demand AI lookup - **Conversational practice** — on-device AI chat partner (Apple Foundation Models) with 10 scenario types; chat bubbles have tappable words that open dictionary / on-demand AI lookup
@@ -29,7 +32,7 @@ A Spanish-learning iOS app that combines verb conjugation practice, a full textb
- **Offline dictionary** — reverse index of 175 K verb forms + 200 common words, cached to disk for instant lookups - **Offline dictionary** — reverse index of 175 K verb forms + 200 common words, cached to disk for instant lookups
- **Vocab SRS review** — spaced repetition over course vocabulary with Again / Hard / Good / Easy rating - **Vocab SRS review** — spaced repetition over course vocabulary with Again / Hard / Good / Easy rating
- **Cloze practice** — fill-in-the-blank sentences with distractor generation - **Cloze practice** — fill-in-the-blank sentences with distractor generation
- **Lyrics practice** — search, translate, and read Spanish song lyrics - **Lyrics practice** — search, translate, and read Spanish song lyrics; long-press any word for an instant definition + tense/person readout (for verbs)
### Tracking & sync ### Tracking & sync
- **Progress** — streaks, daily goals, accuracy stats, achievement badges, study-time tracking per day - **Progress** — streaks, daily goals, accuracy stats, achievement badges, study-time tracking per day
@@ -39,18 +42,21 @@ A Spanish-learning iOS app that combines verb conjugation practice, a full textb
## Architecture ## Architecture
- **SwiftUI** + **SwiftData** with a dual-store configuration: - **SwiftUI** + **SwiftData** with a dual-store configuration:
- **Local store** (App Group `group.com.conjuga.app`) — reference data: verbs, forms, irregular spans, tense guides, course decks, vocab cards, textbook chapters. Seeded from bundled JSON on first launch. Self-healing re-seeds trigger on version bumps *or* if rows are missing on disk. - **Local store** (App Group `group.com.conjuga.app`) — reference data + per-device artifacts: verbs, forms, irregular spans, tense guides, course decks, vocab cards, textbook chapters, and downloaded videos. Seeded from bundled JSON on first launch. Self-healing re-seeds trigger on version bumps *or* if rows are missing on disk.
- **Cloud store** (CloudKit `iCloud.com.conjuga.app`, private database) — user data: review cards, course reviews, user progress, test results, daily logs, saved songs, stories, conversations, textbook exercise attempts. - **Cloud store** (CloudKit `iCloud.com.conjuga.app`, private database) — user data: review cards, course reviews, user progress, test results, daily logs, saved songs, stories, conversations, textbook exercise attempts.
- **SharedModels** Swift Package shared between the app and widget extension. Widget schema must include every local-store entity or SwiftData destructively migrates the shared store. - **SharedModels** Swift Package shared between the app and widget extension. Widget schema must include every local-store entity or SwiftData destructively migrates the shared store.
- **Foundation Models** for on-device AI generation (`@Generable` structs for typed output). - **Foundation Models** for on-device AI generation (`@Generable` structs for typed output) — conversation partner, short stories, verb-detail example sentences.
- **Vision** framework for OCR of textbook pages and vocabulary images. - **Vision** framework for OCR of textbook pages and vocabulary images.
- **Speech** framework for recognition and pronunciation scoring. - **Speech** framework for recognition and pronunciation scoring.
- **YouTubeKit** (SPM) for video stream URL extraction, paired with **AVFoundation** (`AVMutableComposition` + `AVAssetExportSession` passthrough) to mux separate DASH video + audio tracks into a single MP4 when progressive streams aren't available.
- **Textbook extraction pipeline** (`Conjuga/Scripts/textbook/`) — XHTML and answer-key parsers, macOS Vision image OCR + PDF page OCR, bounding-box vocab pair extractor, NSSpellChecker-based validator, and language-aware auto-fixer. - **Textbook extraction pipeline** (`Conjuga/Scripts/textbook/`) — XHTML and answer-key parsers, macOS Vision image OCR + PDF page OCR, bounding-box vocab pair extractor, NSSpellChecker-based validator, and language-aware auto-fixer.
- **Video curation tooling** (`Conjuga/Scripts/generate_videos_markdown.py`) — regenerates `Conjuga/youtube_videos.md` with per-video channel, upload date, duration, view count, and like count (pulled via yt-dlp).
## Requirements ## Requirements
- iOS 18+ (iOS 26 for Foundation Models features) - iOS 26+ (Foundation Models, Liquid Glass, modern Swift concurrency)
- Xcode 16+ - Xcode 26+
- Apple Intelligence-capable device for AI features (conversation partner, AI stories, verb-detail examples); other features degrade gracefully
## Building ## Building
+34 -17
View File
@@ -10,17 +10,30 @@ Side-by-side feature analysis of **Conjuga** (this project), **ConjuGato**, and
**Monetization:** **Monetization:**
**Tech stack:** SwiftUI + SwiftData (dual local / CloudKit stores), SharedModels Swift Package, Foundation Models, Vision, Speech, WidgetKit **Tech stack:** SwiftUI + SwiftData (dual local / CloudKit stores), SharedModels Swift Package, Foundation Models, Vision, Speech, WidgetKit
### Practice Modes The Practice tab is organised into three sections — **Conjugation**, **Vocabulary**, and **Reading**.
### Practice — Conjugation
- **Six core conjugation modes** — flashcards, typing, multiple choice, handwriting (Apple Pencil + finger), sentence builder, full table (all persons at once) - **Six core conjugation modes** — flashcards, typing, multiple choice, handwriting (Apple Pencil + finger), sentence builder, full table (all persons at once)
- **Focus modes** — weak verbs (SM-2 SRS), irregularity drills (spelling / stem / unique, selectable), common tenses - **Focus modes** — weak verbs (SM-2 SRS), irregularity drills (spelling / stem / unique, selectable), common tenses
- **Quick answer review** with per-form irregular-span highlighting - **Quick answer review** with per-form irregular-span highlighting
- **Vocab SRS Review** — spaced repetition over course vocabulary with Again / Hard / Good / Easy rating
- **Cloze practice**fill-in-the-blank with distractor generation from vocab pool ### Practice — Vocabulary
- **Listening practice** — listen-and-type + pronunciation scoring via Speech framework, word-by-word match
- **Vocab Flashcards** — English → Spanish verb recall over the verb table, filtered by enabled Levels (shared with the Verbs-tab filter). Two-layer SRS: a position-based in-session learning queue (Again/Hard requeue 510 cards later, Good moves ~20 ahead, a second Good or Easy graduates) on top of the long-term SM-2 schedule. Due-first session ordering, session size configurable in Settings.
- **Quiz mode** — tap-to-reveal + Again/Hard/Good/Easy rating
- **Learn mode** — both sides shown at once, Next/Previous browsing on a loop, no rating
- **Vocab Multiple Choice** — same pool/SRS; pick the Spanish verb from 4 options, distractors prefer matching part of speech
- **Vocab SRS Review** — spaced repetition over *course* vocabulary (distinct from the verb-table flashcards) with Again / Hard / Good / Easy rating
### Practice — Reading
- **AI short stories** — generated stories with tappable words + comprehension quiz
- **Books** — full-length bilingual EPUB-imported books; chapter reader with tap-to-define, Spanish/English toggle, and read-aloud (TTS with active-word highlighting, tap-to-pause-and-define, voice + speed picker)
- **Lyrics practice** — search Spanish songs, translate line by line - **Lyrics practice** — search Spanish songs, translate line by line
- **Conversational practice** — on-device AI chat partner (Apple Foundation Models) with 10 scenarios, tappable words that open dictionary or on-demand AI lookup - **Conversational practice** — on-device AI chat partner (Apple Foundation Models) with 10 scenarios, tappable words that open dictionary or on-demand AI lookup
- **AI short stories** — generated stories with tappable words + comprehension quiz - **Listening practice** — listen-and-type + pronunciation scoring via Speech framework, word-by-word match
- **Cloze practice** — fill-in-the-blank with distractor generation from vocab pool
### Verb Reference ### Verb Reference
@@ -32,13 +45,15 @@ Side-by-side feature analysis of **Conjuga** (this project), **ConjuGato**, and
### Grammar & Content ### Grammar & Content
- **20 tense guides** with usage rules and examples - **20 tense guides** — teacher-handout depth: usage cases, conjugation tables, common irregulars, mnemonics (WEIRDO, ESCAPA, etc.), pitfalls, and contrast with neighbouring tenses
- **20+ grammar topic notes** (ser/estar, por/para, preterite/imperfect, subjunctive, personal *a*, suffixes, irregular yo forms, stem-changers, etc.) each with 100+ practice exercises - **36 grammar topic notes** (ser/estar, por/para, preterite/imperfect, subjunctive triggers, personal *a*, suffixes, irregular yo forms, stem-changers, etc.) each with a mnemonic, contrast examples, and a common-pitfalls section
- **Grammar exercises** — interactive quizzes for 5 core topics - **Guide cross-links** — tense guides and grammar notes link bidirectionally ("Related grammar" / "Used in tenses" chips)
- **Grammar exercises** — interactive quizzes for core topics
- **Course decks** — weekly vocabulary with example sentences, week tests, cumulative checkpoint exams - **Course decks** — weekly vocabulary with example sentences, week tests, cumulative checkpoint exams
- **Stem-change toggle** on Week 4 decks (E-IE, E-I, O-UE, U-UE) with inline present-tense conjugations - **Extra Study** — star cards during course flashcard review; each week surfaces an "Extra Study" row to drill just the starred cards (iCloud-synced)
- **Textbook reader** — 30 chapters of *Complete Spanish Step-by-Step* with 251 interactive exercises (keyboard + Apple Pencil), 931 OCR'd vocab tables rendered as paired Spanish→English grids (~3 100 cards) - **Textbook reader** — 30 chapters of *Complete Spanish Step-by-Step* with 251 interactive exercises (keyboard + Apple Pencil), vocab tables rendered as paired Spanish→English grids
- **Textbook extraction pipeline** — XHTML + answer-key parsers, macOS Vision image OCR, PDF page OCR, bounding-box vocab pair extractor, NSSpellChecker validator, language-aware auto-fixer - **Textbook extraction pipeline** — XHTML + answer-key parsers, macOS Vision image OCR, PDF page OCR, LLM-vision vocab-pair pass, NSSpellChecker validator, language-aware auto-fixer
- **Books pipeline** (`Scripts/books/`) — EPUB → chapter JSON extractor, Claude-subagent translation pass, bundler
### Offline Dictionary ### Offline Dictionary
@@ -47,10 +62,11 @@ Side-by-side feature analysis of **Conjuga** (this project), **ConjuGato**, and
### Progress & Sync ### Progress & Sync
- **SM-2 spaced repetition** for verb review - **SM-2 spaced repetition** for conjugation review, course vocab, and verb-table vocab (separate `VerbReviewCard` schedule)
- **In-session learning queue** for vocab practice — position-based requeue layered on top of the SM-2 schedule
- **Streaks, daily goals, accuracy stats, achievement badges** - **Streaks, daily goals, accuracy stats, achievement badges**
- **Study-time tracking** per day (foreground time) - **Study-time tracking** per day (foreground time)
- **CloudKit private-database sync** — review cards, user progress, test results, daily logs, saved songs, stories, conversations, textbook exercise attempts - **CloudKit private-database sync** — review cards, verb review cards, user progress, test results, daily logs, saved songs, stories, conversations, textbook exercise attempts, extra-study marks
- **Background app refresh** for widget data - **Background app refresh** for widget data
### Widgets ### Widgets
@@ -61,9 +77,10 @@ Side-by-side feature analysis of **Conjuga** (this project), **ConjuGato**, and
### Settings & Filters ### Settings & Filters
- **Selectable verb level** and **enabled tenses** - **Selectable verb level** (shared between Settings and the Verbs-tab filter) and **enabled tenses**
- **Include vosotros** toggle - **Include vosotros** toggle
- **Auto-fill verb stem** toggle for Full Table practice - **Auto-fill verb stem** toggle for Full Table practice
- **Cards per session** — vocab flashcard / multiple-choice session size (1050 or All)
- **Feature reference** page in Settings documenting every feature and which settings affect it - **Feature reference** page in Settings documenting every feature and which settings affect it
### Data (in repo) ### Data (in repo)
@@ -74,12 +91,12 @@ Side-by-side feature analysis of **Conjuga** (this project), **ConjuGato**, and
| Verb forms (pre-conjugated) | 209,014 | | Verb forms (pre-conjugated) | 209,014 |
| Irregular span annotations | 23,795 | | Irregular span annotations | 23,795 |
| Tenses | 20 | | Tenses | 20 |
| Tense guides | 20 | | Tense guides | 20 (enriched to teacher-handout depth) |
| Grammar notes | 20+ (each with 100+ exercises) | | Grammar notes | 36 |
| Textbook chapters | 30 | | Textbook chapters | 30 |
| Textbook exercises | 251 | | Textbook exercises | 251 |
| Textbook vocab pairs | ~3,118 |
| Offline dictionary forms | 175,425 | | Offline dictionary forms | 175,425 |
| Bundled books | 1 (Olly Richards — *Spanish Short Stories Vol 2*, 13 chapters) |
--- ---