Compare commits

...

43 Commits

Author SHA1 Message Date
Trey T
5b69f3b630 Fixes #19 — Add English translations to exceptional yo form flashcards
Cards now show "tengo — I have" instead of just "tengo", so learners
see the English meaning alongside the Spanish yo form. Bumps course
data version to 6 to trigger re-seed on next launch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:02:40 -05:00
Trey t
ff4f906128 Fix crash from zero-length audio buffers in speech recognition
Guard against empty audio buffers before appending to speech
recognition request — AVAudioBuffer asserts non-zero data size.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:00:51 -05:00
23ff9d66de Merge pull request 'Expand grammar exercises to 100 sentences each' (#18) from feature/expand-grammar-exercises into main 2026-04-13 18:55:56 -05:00
Trey t
b48e935231 Expand grammar exercises to 100 sentences each, pick 10 random per session
- Ser vs Estar: 100 sentences
- Por vs Para: 100 sentences
- Preterite vs Imperfect: 100 sentences
- Subjunctive Triggers: 100 sentences
- Personal A: 100 sentences

Each session randomly selects 10 from the pool for variety.

Closes #15

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:55:36 -05:00
924090190f Merge pull request 'Add Done button to grammar exercise score screen' (#17) from fix/grammar-exercise-back-button into main 2026-04-13 18:49:37 -05:00
Trey t
945b2ff1f3 Add Done button to grammar exercise score screen
Fixes stuck state after completing grammar exercises — adds a
dismiss button on the results screen.

Closes #14

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:49:18 -05:00
77932f802a Merge pull request 'Fix listening practice crash on Start Speaking' (#16) from fix/listening-crash into main 2026-04-13 18:45:28 -05:00
Trey t
5944f263cd Fix listening practice crash when tapping Start Speaking
Wrap startRecording in do/catch so audio setup failures don't crash.
Validate recording format has channels before installTap. Use
DispatchQueue.main.async instead of Task{@MainActor} in recognition
callback to avoid dispatch queue assertions.

Closes #13

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:45:05 -05:00
Trey t
a3318adf5e Use ViewThatFits for study time and activity cards layout
Side by side on iPad, stacked vertically on iPhone. Fixes
calendar grid overflowing on narrow screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:28:02 -05:00
Trey t
a3807faf2d Fix speech authorization crash on device from dispatch queue assertion
Request authorization off main queue and marshal callback result back
via DispatchQueue.main.async. Check current status first to avoid
unnecessary system prompt if already authorized or denied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:19:03 -05:00
93ab7b3e16 Merge pull request 'Add 6 new practice features, offline dictionary, and feature reference' (#12) from newStuff into main 2026-04-13 16:13:15 -05:00
Trey t
a663bc03cd Add 6 new practice features, offline dictionary, and feature reference
New features:
- Offline Dictionary: reverse index of 175K verb forms + 200 common
  words, cached to disk, powers instant word lookups in Stories
- Vocab SRS Review: spaced repetition for course vocabulary cards
  with due count badge and Again/Hard/Good/Easy rating
- Cloze Practice: fill-in-the-blank using SentenceQuizEngine with
  distractor generation from vocabulary pool
- Grammar Exercises: interactive quizzes for 5 grammar topics
  (ser/estar, por/para, preterite/imperfect, subjunctive, personal a)
  with "Practice This" button on grammar note detail
- Listening Practice: listen-and-type + pronunciation check modes
  using Speech framework with word-by-word match scoring
- Conversational Practice: AI chat partner via Foundation Models
  with 10 scenario types, saved to cloud container

Other changes:
- Add Conversation model to SharedModels and cloud container
- Add Info.plist keys for speech recognition and microphone
- Skip speech auth on simulator to prevent crash
- Fix preparing data screen to only show during seed/migration
- Extract courseDataVersion to static property on DataLoader
- Add "How Features Work" reference page in Settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:12:36 -05:00
b13f58ec81 Merge pull request 'Add AI-generated short stories with tappable words' (#11) from feature/short-stories into main 2026-04-13 11:32:19 -05:00
Trey t
451866e988 Add AI-generated short stories with tappable words and comprehension quiz
Generate one-paragraph Spanish stories on-device using Foundation Models,
matched to user's level and enabled tenses. Every word is tappable —
pre-annotated words show instantly, others get a quick on-device AI
lookup with caching. English translation hidden by default behind a
toggle. Comprehension quiz with 3 multiple-choice questions. Stories
saved to cloud container for sync and persistence across resets.

Closes #9

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:31:58 -05:00
0848675016 Merge pull request 'Add irregular verbs reference guides' (#10) from feature/common-irregular-verbs into main 2026-04-13 10:56:49 -05:00
Trey t
79c4c6e290 Add irregular verbs reference guides to grammar section
Add two grammar notes under new "Irregular Verbs" category:
- Most Common Irregular Verbs: 15 essential verbs with present
  and preterite forms plus example sentences
- Types of Irregular Verbs: spelling changes, stem changes, and
  unique irregulars with patterns and examples

Closes #5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:56:34 -05:00
ee8a0c478f Merge pull request 'Fix lyrics wiped on schema reset' (#8) from fix/lyrics-data-loss into main 2026-04-13 10:27:39 -05:00
Trey t
282cd1b3a3 Fix lyrics wiped on schema reset by moving SavedSong to cloud container
SavedSong was in the local container alongside reference data, so it
got deleted whenever localStoreResetVersion was bumped. Move it to the
cloud container (CloudKit-synced) so saved songs persist across schema
changes. Update lyrics views to use cloudModelContextProvider.

Closes #4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:27:21 -05:00
24cc05389e Merge pull request 'Highlight main tenses with Essential badges and focus mode' (#7) from feature/main-tenses-highlight into main 2026-04-13 10:08:02 -05:00
Trey t
40d436ad9c Highlight main tenses with Essential badges and focus mode
Mark 6 core tenses (presente, pretérito, imperfecto, futuro,
subjuntivo presente, imperativo) as essential. Add "Common Tenses"
quick action in Practice to drill only these. Show "Essential"
badge on core tenses in Guide > Tenses list.

Closes #3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:07:46 -05:00
e1b1910c06 Merge pull request 'Add background study timer' (#6) from feature/background-study-timer into main 2026-04-13 09:45:07 -05:00
Trey t
473eb271cc Add background study timer tracking foreground time per day
Track how long users spend studying by timing foreground sessions.
StudyTimerService starts on app active, stops on background, and
accumulates seconds into DailyLog.studySeconds (CloudKit-synced).
Dashboard shows today/total study time with a 7-day bar chart.

Closes #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:44:44 -05:00
Trey t
877e699c56 Add Spanish Suffixes grammar guide with card-based content layout
Add comprehensive suffix reference (diminutives, augmentatives, verb
endings, noun/adjective-forming, adverbs, pejoratives) as grammar note
#21 under Word Building category.

Refactor grammar detail rendering to group paragraphs with their
examples into visual cards, replacing the flat wall-of-text layout.
Suffix entries get pill-styled labels with compact inline examples.
Bump courseDataVersion to 5.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:31:30 -05:00
Trey t
d372a5c77f Add checkpoint exams with cumulative vocabulary review per course
Checkpoint exams appear after each week in the course view, testing
all words from week 1 through the current week within the same course.
Users can choose 25, 50, or 100 questions with even distribution
across weeks. Results are tracked separately from weekly tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:45:25 -05:00
Trey t
a1dc17bf00 Add per-form English translations to verb conjugation table
New EnglishConjugator in SharedModels constructs English translations
by combining the verb's infinitive with person pronouns and tense
auxiliaries (e.g., abatir conditional yo → "I would knock down").
Covers all 20 tense IDs, handles 60+ irregular English verbs,
multi-word verbs, 3rd person rules, gerund and participle formation.

VerbDetailView shows the English below each conjugated form, plus a
legend explaining red = irregular conjugation. 42 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:44:17 -05:00
Trey t
c58313496e Avoid showing the same verb back-to-back in practice
fetchDueCard now accepts the last-shown verb ID and prefers a
different verb from the due queue. Falls back to the same verb
only when it's the sole due card.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:27:29 -05:00
Trey t
636193fae1 Fix lyrics navigation, translation line alignment, and store reset
Navigation: present search as a sheet from library (avoids nested
NavigationStack), use view-based NavigationLink for song rows (fixes
double-push from duplicate navigationDestination).

Translation: Apple Translation inserts a blank line after every
translated line. Strip all blanks from the EN output, then re-insert
them at the same positions where the original ES has blanks. Result
is 1:1 line pairing between Spanish and English.

Store reset: revert localStoreResetVersion bump — adding SavedSong
is a lightweight SwiftData migration, no store nuke needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:08:32 -05:00
Trey t
faef20e5b8 Add Lyrics practice: search, translate, and read Spanish song lyrics
New feature in the Practice tab that lets users search for Spanish songs
by artist + title, fetch lyrics from LRCLIB (free, no API key), pull
album art from iTunes Search API, auto-translate to English via Apple's
on-device Translation framework, and save for offline reading.

Components:
- SavedSong SwiftData model (local container, no CloudKit sync)
- LyricsSearchService actor (LRCLIB + iTunes Search, concurrent)
- LyricsSearchView (artist/song fields, result list with album art)
- LyricsConfirmationView (lyrics preview, auto-translation, save)
- LyricsLibraryView (saved songs list, swipe to delete)
- LyricsReaderView (Spanish lines with English subtitles)
- Practice tab integration (Lyrics button with NavigationLink)
- localStoreResetVersion bumped to 3 for schema migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:44:40 -05:00
Trey t
5fa1cc3921 Filter phonetic glosses from Complete the Sentence quiz
Examples shorter than 4 words (like pronunciation guides
"discutir(dees-koo-teer)") are now rejected by both
isBlankResolvable and buildQuestion. The engine only picks real
multi-word sentences for the quiz prompt.

Every card already has at least one real sentence alongside its
phonetic entries, so no data regeneration is needed — the filter
alone fixes the issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:22:28 -05:00
Trey t
a51d2abd47 Defer AVSpeechSynthesisVoice init to first speak() call
AVSpeechSynthesisVoice(language:) triggers a malloc double-free on
iOS 26 simulators when deserializing voice metadata during app launch.
Move voice resolution from init() to first speak() so the framework
call happens after the app is fully initialized.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:52:56 -05:00
Trey t
2a062cf484 Bump courseDataVersion to 4 for sentence gap-fill re-seed
Existing installs will delete and re-seed all VocabCard/CourseDeck data
on next launch, picking up the ~6,300 new example sentences and blank
fields added for the Complete the Sentence quiz type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:47:31 -05:00
Trey t
02e8d5141a Complete the Sentence: fill sentences for final batches 26-29
290 cards: Intermediate III tail (48), Advanced I (95), Advanced II
(147). All 8 courses now have complete sentence coverage for the
Complete the Sentence quiz type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:43:49 -05:00
Trey t
cd67f32302 Complete the Sentence: fill sentences for Intermediate III batches 23-25
300 cards across Intermediate III Spanish Through Stories weeks 1-6.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:34:19 -05:00
Trey t
79d9b7cb1d Complete the Sentence: fill sentences for Intermediate II batches 20-22
219 cards: Intermediate II batches 20, 21, 22 — clothing, business,
beliefs, citizenship, addictions, authority, rest. Intermediate II
now 100% complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:27:51 -05:00
Trey t
d666d0991a Complete the Sentence: fill sentences for batches 17-19
248 cards: Intermediate I finishing (148 cards across batches 17-18)
and Intermediate II batch 19 (100 cards).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:22:08 -05:00
Trey t
4e575a22c8 Complete the Sentence: fill sentences for batches 14-16
294 cards: Beginner III Conversation finishing (194 cards) and
Intermediate I batch 16 (100 cards).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:15:01 -05:00
Trey t
d538123251 Complete the Sentence: fill sentences for batches 11-13
289 cards: Beginner II batches 11-12 finishing (200 cards) and Beginner
III Conversation batch 13 (100 cards).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:09:23 -05:00
Trey t
54c1b05411 Complete the Sentence: fill sentences for Beginner II batches 8-10
300 cards across Beginner II weeks 1-5: body parts, animals, verbos
como gustar, food, clothing, city expansions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:04:07 -05:00
Trey t
99fc3c91f5 Complete the Sentence: fill sentences for Beginner I batches 5-7/7
233 cards across Beginner I weeks 4-8: Adjectives, Family reversed,
stem-changing verbs, reflexives, Daily Routine, City, Time/Seasons,
tener idioms, Hobbies. Finishes Beginner I content (508 cards total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:58:45 -05:00
Trey t
ca7640b100 Complete the Sentence: fill sentences for Beginner I batches 2-4/7
300 cards across Beginner I weeks 2-4: Adjectives, Numbers, Professions,
House, -AR/-ER/-IR verbs, Family. Each card now has at least 3 example
sentences with stored blank fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:53:37 -05:00
Trey t
719134c6c7 Complete the Sentence: fill sentences for Beginner I batch 1/7
100 cards from Beginner I — Greetings / Basic Verbs / early decks.
Each card now has at least 3 example sentences with a stored `blank`
field identifying the exact substring to hide in the quiz.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:47:48 -05:00
Trey t
143e356b75 Complete the Sentence quiz type: engine, UI, tests
Add a new Complete the Sentence quiz type that renders a Spanish example
sentence from the card with the target word blanked out and asks the
student to pick the missing word from 4 choices (other cards' fronts
from the same week's pool).

Core logic lives in SharedModels/SentenceQuizEngine as pure functions
over VocabCard, covered by 18 Swift Testing tests. CourseQuizView calls
the engine, pre-filters the card pool to cards that can produce a
resolvable blank, and reuses the existing MC rendering via a new
correctAnswer(for:) helper.

VocabCard gains examplesBlanks (parallel array to examplesES) so content
can explicitly tag the blanked substring; DataLoader reads an optional
"blank" key on each example. Additive schema change, CloudKit-safe
default.

Also adds ContentCoverageTests that parse the repo's course_data.json
and assert every card has >=3 examples and yields a resolvable question.
These tests currently fail: 1,117 cards still need sentences. They are
the oracle for the gap-fill pass that follows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:33:50 -05:00
Trey t
3b8a8a7f1a Guard quiz Next/Previous against double taps
A rapid second tap on Next (easy to trigger with Apple Pencil) called
advance() twice before the view tree re-rendered, skipping a card.
Share a single isAdvancing flag between advance() and a new goBack()
helper, disable both buttons while the flag is set, and clear it after
350 ms so queued Pencil events are absorbed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:52:08 -05:00
54 changed files with 6936 additions and 160 deletions

View File

@@ -29,16 +29,20 @@
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */; };
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
@@ -48,6 +52,7 @@
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
@@ -62,6 +67,7 @@
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
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 */; };
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
@@ -72,6 +78,22 @@
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; };
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327659ABFD524514B6D2D505 /* StoryGenerator.swift */; };
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; };
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; };
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; };
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */; };
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3698CE7ACF148318615293E /* VocabReviewView.swift */; };
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A649B04B8B3C49419AD9219C /* ClozeView.swift */; };
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */; };
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */; };
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D535EF6988A24B47B70209A2 /* PronunciationService.swift */; };
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2179562E54E148C98219D /* ListeningView.swift */; };
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10603F454E54341AA4B9931 /* ConversationService.swift */; };
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */; };
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */; };
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -122,19 +144,24 @@
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
3EA01795655C444795577A22 /* LyricsConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsConfirmationView.swift; sourceTree = "<group>"; };
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNotesView.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>"; };
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = "<group>"; };
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; };
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.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>"; };
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
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>"; };
@@ -168,6 +195,23 @@
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.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>"; };
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -201,6 +245,7 @@
7E6AF62A3A949630E067DC22 /* Info.plist */,
353C5DE41FD410FA82E3AED7 /* Models */,
1994867BC8E985795A172854 /* Services */,
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
3C75490F53C34A37084FF478 /* ViewModels */,
A81CA75762B08D35D5B7A44D /* Views */,
);
@@ -211,6 +256,7 @@
isa = PBXGroup;
children = (
BCCC95A95581458E068E0484 /* SettingsView.swift */,
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
);
path = Settings;
sourceTree = "<group>";
@@ -231,7 +277,13 @@
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
E10603F454E54341AA4B9931 /* ConversationService.swift */,
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
777C696A841803D5B775B678 /* ReferenceStore.swift */,
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
@@ -263,7 +315,8 @@
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */,
);
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */,
);
path = Models;
sourceTree = "<group>";
};
@@ -314,9 +367,15 @@
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
10C16AA6022E4742898745CE /* TypingView.swift */,
);
895E547BEFB5D0FBF676BE33 /* Lyrics */,
8A1DED0596E04DDE9536A9A9 /* Stories */,
DFD75E32A53845A693D98F48 /* Chat */,
02B2179562E54E148C98219D /* ListeningView.swift */,
A649B04B8B3C49419AD9219C /* ClozeView.swift */,
);
path = Practice;
sourceTree = "<group>";
};
@@ -325,9 +384,40 @@
children = (
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
);
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */,
);
path = Guide;
sourceTree = "<group>";
};
DFD75E32A53845A693D98F48 /* Chat */ = {
isa = PBXGroup;
children = (
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */,
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */,
);
path = Chat;
sourceTree = "<group>";
};
8A1DED0596E04DDE9536A9A9 /* Stories */ = {
isa = PBXGroup;
children = (
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */,
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */,
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */,
);
path = Stories;
sourceTree = "<group>";
};
895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
isa = PBXGroup;
children = (
3EA01795655C444795577A22 /* LyricsConfirmationView.swift */,
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */,
58394296923991E56BAC2B02 /* LyricsReaderView.swift */,
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */,
);
path = Lyrics;
sourceTree = "<group>";
};
A591A3B6F1F13D23D68D7A9D = {
isa = PBXGroup;
@@ -372,10 +462,18 @@
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */,
);
path = Course;
sourceTree = "<group>";
};
BFC1AEBE02CE22E6474FFEA6 /* Utilities */ = {
isa = PBXGroup;
children = (
);
path = Utilities;
sourceTree = "<group>";
};
F605D24E5EA11065FD18AF7E /* Products */ = {
isa = PBXGroup;
children = (
@@ -518,6 +616,11 @@
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */,
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
@@ -548,8 +651,25 @@
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
);
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */,
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */,
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */,
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */,
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */,
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */,
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */,
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */,
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */,
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */,
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */,
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */,
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */,
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */,
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */,
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
217A29BCEDD9D44B6DD85AF6 /* Sources */ = {

View File

@@ -10,7 +10,7 @@ private enum CloudPreviewContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
return try! ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
configurations: configuration
)
}()
@@ -36,8 +36,10 @@ extension EnvironmentValues {
struct ConjugaApp: App {
@AppStorage("onboardingComplete") private var onboardingComplete = false
@Environment(\.scenePhase) private var scenePhase
@State private var isReady = false
@State private var isReady = true
@State private var syncMonitor = SyncStatusMonitor()
@State private var studyTimer = StudyTimerService()
@State private var dictionary = DictionaryService()
let localContainer: ModelContainer
let cloudContainer: ModelContainer
@@ -66,15 +68,16 @@ struct ConjugaApp: App {
"cloud",
schema: Schema([
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
]),
cloudKitDatabase: .private("iCloud.com.conjuga.app")
)
cloudContainer = try ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
configurations: cloudConfig
)
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
@@ -106,15 +109,23 @@ struct ConjugaApp: App {
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
.environment(syncMonitor)
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
.environment(studyTimer)
.environment(dictionary)
.task {
if let url = SharedStore.localStoreURL() {
StoreInspector.dump(at: url, label: "before-bootstrap")
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
if needsSeed {
isReady = false
}
await StartupCoordinator.bootstrap(localContainer: localContainer)
if let url = SharedStore.localStoreURL() {
StoreInspector.dump(at: url, label: "after-bootstrap")
if !isReady {
isReady = true
}
Task { @MainActor in
dictionary.buildIfNeeded(context: localContainer.mainContext)
}
isReady = true
Task { @MainActor in
syncMonitor.beginSync()
@@ -130,6 +141,15 @@ struct ConjugaApp: App {
}
}
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .active:
studyTimer.start()
case .inactive, .background:
studyTimer.stop(context: cloudContainer.mainContext)
@unknown default:
break
}
if newPhase == .background {
WidgetDataService.update(
localContainer: localContainer,
@@ -178,7 +198,7 @@ struct ConjugaApp: App {
deleteStoreFiles(at: url)
UserDefaults.standard.removeObject(forKey: "courseDataVersion")
print("Reset corrupted local reference store")
print("[ConjugaApp] ⚠️ Reset corrupted local reference store — this triggers full re-seed")
return try makeLocalContainer(at: url)
}
@@ -224,7 +244,7 @@ struct ConjugaApp: App {
/// Clears accumulated stale schema metadata from previous container configurations.
/// Bump the version number to force another reset if the schema changes again.
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
let resetVersion = 2 // bump: widget schema moved to SharedModels
let resetVersion = 3 // bump: SavedSong moved to cloud container
let key = "localStoreResetVersion"
let defaults = UserDefaults.standard
@@ -239,4 +259,5 @@ struct ConjugaApp: App {
defaults.set(resetVersion, forKey: key)
}
}

View File

@@ -24,6 +24,10 @@
<string>public.app-category.education</string>
<key>UILaunchScreen</key>
<dict/>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Conjuga uses speech recognition to check your Spanish pronunciation and transcribe what you say during listening exercises.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Conjuga needs microphone access to record your voice for pronunciation practice.</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>

View File

@@ -7,6 +7,7 @@ final class DailyLog {
var dateString: String = ""
var reviewCount: Int = 0
var correctCount: Int = 0
var studySeconds: Int = 0
var accuracy: Double {
guard reviewCount > 0 else { return 0 }

View File

@@ -0,0 +1,569 @@
import Foundation
struct GrammarExercise: Identifiable, Hashable {
let id: String
let prompt: String
let sentence: String
let correctAnswer: String
let options: [String]
let explanation: String
static func exercises(for noteId: String) -> [GrammarExercise] {
switch noteId {
case "ser-vs-estar": return serVsEstarExercises
case "por-vs-para": return porVsParaExercises
case "preterite-vs-imperfect": return preteriteVsImperfectExercises
case "subjunctive-triggers": return subjunctiveTriggerExercises
case "personal-a": return personalAExercises
default: return []
}
}
// MARK: - Ser vs Estar (100)
private static let serVsEstarExercises: [GrammarExercise] = {
let data: [(String, String, String, String)] = [
// (sentence, correct, wrong, explanation)
("Ella _____ doctora.", "es", "está", "Ser for professions."),
("El libro _____ en la mesa.", "está", "es", "Estar for location."),
("Yo _____ muy cansado hoy.", "estoy", "soy", "Estar for temporary states."),
("Nosotros _____ de México.", "somos", "estamos", "Ser for origin."),
("La sopa _____ caliente.", "está", "es", "Estar for conditions."),
("_____ las tres de la tarde.", "Son", "Están", "Ser for telling time."),
("Mi hermano _____ alto.", "es", "está", "Ser for physical descriptions."),
("Ella _____ feliz porque aprobó.", "está", "es", "Estar for emotions."),
("La casa _____ grande.", "es", "está", "Ser for inherent qualities."),
("El café _____ frío.", "está", "es", "Estar for current condition."),
("Ellos _____ estudiantes.", "son", "están", "Ser for identity."),
("Yo _____ en la oficina.", "estoy", "soy", "Estar for location."),
("La fiesta _____ en mi casa.", "es", "está", "Ser for events (location of event)."),
("Tú _____ muy inteligente.", "eres", "estás", "Ser for personality traits."),
("El agua _____ fría.", "está", "es", "Estar for temperature (current state)."),
("María _____ de España.", "es", "está", "Ser for origin."),
("Nosotros _____ listos para salir.", "estamos", "somos", "Estar — ready (temporary state)."),
("Él _____ un buen amigo.", "es", "está", "Ser for characteristics."),
("La puerta _____ abierta.", "está", "es", "Estar for states resulting from actions."),
("Hoy _____ lunes.", "es", "está", "Ser for days/dates."),
("Yo _____ aburrido en clase.", "estoy", "soy", "Estar — bored (feeling now)."),
("Ella _____ aburrida como persona.", "es", "está", "Ser — boring (personality)."),
("La manzana _____ verde.", "está", "es", "Estar — unripe (condition)."),
("La camisa _____ de algodón.", "es", "está", "Ser for material."),
("Él _____ enfermo.", "está", "es", "Estar for health conditions."),
("Nosotros _____ contentos.", "estamos", "somos", "Estar for emotions."),
("La clase _____ a las ocho.", "es", "está", "Ser for scheduled time."),
("Tú _____ muy guapo hoy.", "estás", "eres", "Estar — looking good (today)."),
("Ella _____ profesora de español.", "es", "está", "Ser for profession."),
("El examen _____ difícil.", "es", "está", "Ser for inherent characteristic."),
("Yo _____ nervioso por el examen.", "estoy", "soy", "Estar for temporary feeling."),
("Los niños _____ en el parque.", "están", "son", "Estar for location."),
("La película _____ interesante.", "es", "está", "Ser for inherent quality."),
("El restaurante _____ cerrado.", "está", "es", "Estar for state (closed now)."),
("Mi padre _____ alto y moreno.", "es", "está", "Ser for physical description."),
("¿Dónde _____ el baño?", "está", "es", "Estar for location."),
("Ella _____ lista.", "es", "está", "Ser — clever (trait)."),
("¿Cómo _____ tú?", "estás", "eres", "Estar — how are you (state)."),
("La comida _____ deliciosa.", "está", "es", "Estar — tastes delicious (now)."),
("Él _____ colombiano.", "es", "está", "Ser for nationality."),
("Yo _____ preocupado.", "estoy", "soy", "Estar for worry (emotion)."),
("La mesa _____ de madera.", "es", "está", "Ser for material."),
("Ellos _____ cansados después del viaje.", "están", "son", "Estar for temporary state."),
("Mi madre _____ muy joven.", "es", "está", "Ser for age/appearance (inherent)."),
("El cielo _____ nublado.", "está", "es", "Estar for weather conditions."),
("Nosotros _____ hermanos.", "somos", "estamos", "Ser for relationships."),
("La ventana _____ rota.", "está", "es", "Estar for result of action."),
("¿Quién _____ tu profesor?", "es", "está", "Ser for identity."),
("El bebé _____ dormido.", "está", "es", "Estar for state (sleeping)."),
("Ella _____ muy trabajadora.", "es", "está", "Ser for personality."),
("Yo _____ listo para el examen.", "estoy", "soy", "Estar — ready."),
("La ciudad _____ bonita.", "es", "está", "Ser for inherent beauty."),
("Tú _____ sentado en mi silla.", "estás", "eres", "Estar for position/posture."),
("El problema _____ complicado.", "es", "está", "Ser for inherent quality."),
("La leche _____ en el refrigerador.", "está", "es", "Estar for location."),
("Yo _____ mexicano.", "soy", "estoy", "Ser for nationality."),
("Ella _____ embarazada.", "está", "es", "Estar for temporary condition."),
("La reunión _____ a las diez.", "es", "está", "Ser for scheduled time."),
("El perro _____ sucio.", "está", "es", "Estar for current condition."),
("Nosotros _____ amigos desde niños.", "somos", "estamos", "Ser for relationships."),
("Tú _____ muy callado hoy.", "estás", "eres", "Estar — quiet today (temporary)."),
("Ella _____ la directora.", "es", "está", "Ser for identity/role."),
("El coche _____ nuevo.", "es", "está", "Ser for characteristic."),
("Yo _____ seguro de eso.", "estoy", "soy", "Estar for certainty (state)."),
("La silla _____ rota.", "está", "es", "Estar for broken (result of action)."),
("Mi casa _____ cerca del parque.", "está", "es", "Estar for relative location."),
("Él _____ viejo.", "es", "está", "Ser for age."),
("El café _____ listo.", "está", "es", "Estar — ready (state)."),
("Nosotros _____ perdidos.", "estamos", "somos", "Estar for being lost."),
("La respuesta _____ correcta.", "es", "está", "Ser for fact."),
("Tú _____ enojado conmigo.", "estás", "eres", "Estar for emotion."),
("Ella _____ rica.", "es", "está", "Ser for wealth (inherent)."),
("El museo _____ en el centro.", "está", "es", "Estar for location."),
("Yo _____ de acuerdo.", "estoy", "soy", "Estar for agreement (state)."),
("La luz _____ encendida.", "está", "es", "Estar for state (on/off)."),
("Ellos _____ gemelos.", "son", "están", "Ser for identity."),
("El clima _____ agradable.", "está", "es", "Estar for weather now."),
("La tarea _____ para mañana.", "es", "está", "Ser for deadline."),
("Yo _____ ocupado ahora.", "estoy", "soy", "Estar for temporary state."),
("Ella _____ soltera.", "es", "está", "Ser for marital status."),
("El pan _____ duro.", "está", "es", "Estar for condition (stale)."),
("Mi hermana _____ mayor que yo.", "es", "está", "Ser for comparison."),
("Tú _____ mojado por la lluvia.", "estás", "eres", "Estar for condition."),
("La cena _____ a las nueve.", "es", "está", "Ser for time."),
("El hospital _____ lejos.", "está", "es", "Estar for distance/location."),
("Nosotros _____ orgullosos de ti.", "estamos", "somos", "Estar for emotion."),
("Ella _____ muy simpática.", "es", "está", "Ser for personality."),
("El gato _____ debajo de la cama.", "está", "es", "Estar for location."),
("Yo _____ vegetariano.", "soy", "estoy", "Ser for identity."),
("La ventana _____ sucia.", "está", "es", "Estar for condition."),
("Él _____ contento con su trabajo.", "está", "es", "Estar for satisfaction."),
("La prueba _____ fácil.", "es", "está", "Ser for inherent quality."),
("Tú _____ de buen humor.", "estás", "eres", "Estar for mood."),
("El vuelo _____ a las seis.", "es", "está", "Ser for scheduled time."),
("La playa _____ hermosa.", "es", "está", "Ser for inherent beauty."),
("Yo _____ emocionado por el viaje.", "estoy", "soy", "Estar for excitement."),
("Ellos _____ en casa.", "están", "son", "Estar for location."),
("La tienda _____ abierta.", "está", "es", "Estar for state."),
("Él _____ el mejor jugador.", "es", "está", "Ser for identity/superlative."),
("Nosotros _____ sorprendidos.", "estamos", "somos", "Estar for emotion."),
]
return data.enumerated().map { i, d in
GrammarExercise(id: "se\(i+1)", prompt: "Choose ser or estar:", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
}
}()
// MARK: - Por vs Para (100)
private static let porVsParaExercises: [GrammarExercise] = {
let data: [(String, String, String, String)] = [
("Este regalo es _____ ti.", "para", "por", "Para for recipient."),
("Gracias _____ tu ayuda.", "por", "para", "Por for cause/reason."),
("Caminamos _____ el parque.", "por", "para", "Por for movement through."),
("Estudio _____ aprender.", "para", "por", "Para for purpose."),
("Pagué veinte dólares _____ el libro.", "por", "para", "Por for exchange."),
("Salimos _____ Madrid mañana.", "para", "por", "Para for destination."),
("Estudié _____ dos horas.", "por", "para", "Por for duration."),
("Necesito el informe _____ el lunes.", "para", "por", "Para for deadline."),
("Te llamo _____ teléfono.", "por", "para", "Por for means."),
("Trabajo _____ una empresa grande.", "para", "por", "Para for employer."),
("Pasamos _____ tu casa ayer.", "por", "para", "Por for passing by."),
("La carta fue escrita _____ María.", "por", "para", "Por for agent in passive."),
("Este medicamento es _____ el dolor.", "para", "por", "Para for purpose."),
("Viajamos _____ avión.", "por", "para", "Por for means of transport."),
("_____ favor, ayúdame.", "Por", "Para", "Fixed expression: por favor."),
("Voy _____ agua.", "por", "para", "Por for going to get something."),
("_____ ser estudiante, habla muy bien.", "Para", "Por", "Para for comparison/considering."),
("Lo hice _____ ti.", "por", "para", "Por for on behalf of."),
("Este libro es _____ niños.", "para", "por", "Para for intended audience."),
("_____ supuesto que sí.", "Por", "Para", "Fixed expression: por supuesto."),
("Necesito lentes _____ leer.", "para", "por", "Para for purpose (in order to)."),
("Luchamos _____ la libertad.", "por", "para", "Por for cause worth fighting for."),
("Cambié mi coche _____ uno nuevo.", "por", "para", "Por for exchange."),
("Vamos _____ la costa.", "para", "por", "Para for destination."),
("_____ ejemplo, esto es fácil.", "Por", "Para", "Fixed expression: por ejemplo."),
("Mandé el paquete _____ correo.", "por", "para", "Por for means."),
("Compré flores _____ mi madre.", "para", "por", "Para for recipient."),
("Corrieron _____ la calle.", "por", "para", "Por for through/along."),
("Estudia mucho _____ sacar buenas notas.", "para", "por", "Para for goal."),
("_____ eso no vine.", "Por", "Para", "Por for reason (that's why)."),
("Ella trabaja _____ ganar dinero.", "para", "por", "Para for purpose."),
("Fueron criticados _____ los medios.", "por", "para", "Por for agent in passive."),
("Tengo un mensaje _____ usted.", "para", "por", "Para for recipient."),
("Votamos _____ el candidato.", "por", "para", "Por for in favor of."),
("_____ lo menos, intenta.", "Por", "Para", "Fixed expression: por lo menos."),
("La clase es _____ principiantes.", "para", "por", "Para for intended audience."),
("Pagamos mucho _____ la cena.", "por", "para", "Por for exchange."),
("Salgo _____ el aeropuerto a las cinco.", "para", "por", "Para for destination."),
("Esperamos _____ una hora.", "por", "para", "Por for duration."),
("_____ fin llegamos.", "Por", "Para", "Fixed expression: por fin."),
("¿_____ qué estudias español?", "Por", "Para", "Por qué — asking for reason."),
("¿_____ cuándo es el proyecto?", "Para", "Por", "Para for deadline."),
("Lo terminé _____ la noche.", "por", "para", "Por for time of day (general)."),
("Este dinero es _____ la renta.", "para", "por", "Para for purpose/intended use."),
("_____ mí, está bien.", "Para", "Por", "Para for opinion (in my view)."),
("Ella habla _____ todos nosotros.", "por", "para", "Por for on behalf of."),
("Voy a estar aquí _____ tres semanas.", "por", "para", "Por for duration."),
("Estas vitaminas son _____ la salud.", "para", "por", "Para for purpose."),
("Navegamos _____ el río.", "por", "para", "Por for along/through."),
("La tarea es _____ mañana.", "para", "por", "Para for deadline."),
("Fue elegido _____ el pueblo.", "por", "para", "Por for agent."),
("Estamos aquí _____ ayudarte.", "para", "por", "Para for purpose."),
("Me preocupo _____ mi familia.", "por", "para", "Por for concern about."),
("Hay una sorpresa _____ ti.", "para", "por", "Para for recipient."),
("_____ siempre te amaré.", "Para", "Por", "Fixed expression: para siempre."),
("Vendí el coche _____ cinco mil.", "por", "para", "Por for price/exchange."),
("Ella se fue _____ la mañana.", "por", "para", "Por for general time."),
("Este regalo es perfecto _____ ella.", "para", "por", "Para for recipient."),
("Brindamos _____ tu éxito.", "por", "para", "Por for toasting/in honor of."),
("Necesito un traje _____ la boda.", "para", "por", "Para for occasion."),
("Caminé _____ la playa al atardecer.", "por", "para", "Por for along."),
("_____ nada, fue un placer.", "De", "Para", "Actually 'de nada' — trick question. Skip."),
("Me quedé en casa _____ la lluvia.", "por", "para", "Por for cause."),
("Ahorro dinero _____ comprar una casa.", "para", "por", "Para for goal."),
("El tren pasa _____ aquí.", "por", "para", "Por for through/by here."),
("Tengo algo especial _____ ti.", "para", "por", "Para for recipient."),
("Lo dejé _____ después.", "para", "por", "Para for later (intended time)."),
("Murió _____ su país.", "por", "para", "Por for sacrifice/cause."),
("La reunión es _____ las dos.", "para", "por", "Para for deadline/scheduled."),
("Pregunté _____ ti en la fiesta.", "por", "para", "Por for asking about someone."),
("Estudio español _____ mi trabajo.", "para", "por", "Para for purpose."),
("_____ lo general, como a las doce.", "Por", "Para", "Fixed expression: por lo general."),
("Hice la comida _____ los invitados.", "para", "por", "Para for recipients."),
("Ella está aquí _____ unas semanas.", "por", "para", "Por for duration."),
("El avión sale _____ Buenos Aires.", "para", "por", "Para for destination."),
("Cambié euros _____ dólares.", "por", "para", "Por for exchange."),
("Corro _____ mantenerme en forma.", "para", "por", "Para for purpose."),
("Fueron aplaudidos _____ el público.", "por", "para", "Por for agent."),
("Ven _____ acá.", "para", "por", "Para for direction toward."),
("_____ suerte, no pasó nada.", "Por", "Para", "Fixed expression: por suerte."),
("Compré una torta _____ su cumpleaños.", "para", "por", "Para for occasion."),
("Viajé _____ toda Europa.", "por", "para", "Por for throughout."),
("El informe es _____ el director.", "para", "por", "Para for recipient."),
("Llegué tarde _____ el tráfico.", "por", "para", "Por for cause."),
("Está listo _____ servir.", "para", "por", "Para for readiness/purpose."),
("Doy gracias _____ todo.", "por", "para", "Por for gratitude about."),
("Este postre es _____ compartir.", "para", "por", "Para for intended use."),
("Fui al mercado _____ frutas.", "por", "para", "Por for going to fetch."),
("La canción fue compuesta _____ él.", "por", "para", "Por for agent."),
("Traje comida _____ todos.", "para", "por", "Para for recipients."),
("Nos fuimos _____ la puerta de atrás.", "por", "para", "Por for through/via."),
("Ella cocina _____ su familia.", "para", "por", "Para for beneficiary."),
("Dieron su vida _____ la patria.", "por", "para", "Por for sacrifice."),
("Tengo una cita _____ el miércoles.", "para", "por", "Para for deadline/date."),
("Lo hago _____ amor.", "por", "para", "Por for motivation."),
("_____ colmo, empezó a llover.", "Para", "Por", "Fixed expression: para colmo."),
("Mandamos invitaciones _____ correo.", "por", "para", "Por for means."),
("Vamos a brindar _____ los novios.", "por", "para", "Por for in honor of."),
("Reservé una mesa _____ cuatro personas.", "para", "por", "Para for intended use."),
]
return data.enumerated().map { i, d in
GrammarExercise(id: "pp\(i+1)", prompt: "Choose por or para:", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
}
}()
// MARK: - Preterite vs Imperfect (100)
private static let preteriteVsImperfectExercises: [GrammarExercise] = {
let data: [(String, String, String, String)] = [
("Ayer _____ una pizza. (comer, yo)", "comí", "comía", "Preterite — completed action (ayer)."),
("Cuando era niño, _____ en el parque. (jugar, yo)", "jugaba", "jugué", "Imperfect — habitual past action."),
("Ella _____ a las ocho. (llegar)", "llegó", "llegaba", "Preterite — single completed event."),
("_____ sol y los pájaros cantaban. (hacer)", "Hacía", "Hizo", "Imperfect — background description."),
("De repente, _____ el teléfono. (sonar)", "sonó", "sonaba", "Preterite — sudden event (de repente)."),
("Siempre _____ juntos los domingos. (comer, nosotros)", "comíamos", "comimos", "Imperfect — habitual (siempre)."),
("Ayer _____ al cine. (ir, nosotros)", "fuimos", "íbamos", "Preterite — specific completed action."),
("Cuando _____ joven, viajaba mucho. (ser, yo)", "era", "fui", "Imperfect — ongoing past state."),
("Anoche _____ una película muy buena. (ver, yo)", "vi", "veía", "Preterite — specific time (anoche)."),
("Todos los días _____ a la escuela. (caminar, ella)", "caminaba", "caminó", "Imperfect — habitual (todos los días)."),
("El año pasado _____ a España. (viajar, ellos)", "viajaron", "viajaban", "Preterite — specific time (el año pasado)."),
("Mientras yo _____, ella cocinaba. (estudiar)", "estudiaba", "estudié", "Imperfect — simultaneous background."),
("_____ las diez cuando llegamos. (ser)", "Eran", "Fueron", "Imperfect — time description."),
("Él _____ la puerta y salió. (abrir)", "abrió", "abría", "Preterite — sequential action."),
("De niña, _____ helado cada viernes. (comer, ella)", "comía", "comió", "Imperfect — habitual (de niña)."),
("_____ mucho frío ese día. (hacer)", "Hacía", "Hizo", "Imperfect — weather description."),
("Una vez, _____ a un famoso. (conocer, yo)", "conocí", "conocía", "Preterite — met for first time."),
("Yo _____ a Juan desde niño. (conocer)", "conocía", "conocí", "Imperfect — ongoing familiarity."),
("_____ la verdad ayer. (saber, yo)", "Supe", "Sabía", "Preterite — found out (new info)."),
("Yo _____ la verdad todo el tiempo. (saber)", "sabía", "supe", "Imperfect — knew (ongoing)."),
("Ella _____ un vestido azul. (llevar)", "llevaba", "llevó", "Imperfect — description of what she was wearing."),
("Él _____ el vaso y se rompió. (dejar caer)", "dejó caer", "dejaba caer", "Preterite — single event."),
("Generalmente _____ a las siete. (despertarse, yo)", "me despertaba", "me desperté", "Imperfect — habitual (generalmente)."),
("Esa noche _____ mucho. (llover)", "llovió", "llovía", "Preterite — bounded event (esa noche)."),
("_____ lloviendo cuando salí. (estar)", "Estaba", "Estuvo", "Imperfect — ongoing background."),
("Yo _____ cuando sonó la alarma. (dormir)", "dormía", "dormí", "Imperfect — interrupted background."),
("Ella _____ tres libros el verano pasado. (leer)", "leyó", "leía", "Preterite — counted completed actions."),
("Antes, _____ mucho café. (tomar, yo)", "tomaba", "tomé", "Imperfect — habitual (antes)."),
("El lunes _____ al médico. (ir, yo)", "fui", "iba", "Preterite — specific day."),
("Cada verano _____ a la playa. (ir, nosotros)", "íbamos", "fuimos", "Imperfect — habitual (cada verano)."),
("Él me _____ un secreto. (contar)", "contó", "contaba", "Preterite — single event."),
("Ella siempre me _____ historias. (contar)", "contaba", "contó", "Imperfect — habitual (siempre)."),
("_____ mucha gente en la fiesta. (haber)", "Había", "Hubo", "Imperfect — scene description."),
("_____ un accidente en la autopista. (haber)", "Hubo", "Había", "Preterite — single event."),
("Cuando _____ al parque, vi a Juan. (llegar, yo)", "llegué", "llegaba", "Preterite — completed action."),
("Mientras _____ al parque, vi a Juan. (caminar, yo)", "caminaba", "caminé", "Imperfect — ongoing when interrupted."),
("Ella _____ la guitarra de joven. (tocar)", "tocaba", "tocó", "Imperfect — used to (habitual)."),
("Ayer ella _____ la guitarra en el concierto. (tocar)", "tocó", "tocaba", "Preterite — specific event."),
("Mi abuela _____ muy bien. (cocinar)", "cocinaba", "cocinó", "Imperfect — description of ability."),
("Mi abuela _____ una paella ayer. (cocinar)", "cocinó", "cocinaba", "Preterite — specific completed action."),
("Yo _____ quince años cuando nos mudamos. (tener)", "tenía", "tuve", "Imperfect — age as background."),
("Él _____ un accidente terrible. (tener)", "tuvo", "tenía", "Preterite — single event."),
("Nosotros _____ en esa casa por diez años. (vivir)", "vivimos", "vivíamos", "Preterite — bounded duration (completed)."),
("Nosotros _____ en esa casa cuando era niño. (vivir)", "vivíamos", "vivimos", "Imperfect — ongoing past setting."),
("Ella _____ y se fue. (levantarse)", "se levantó", "se levantaba", "Preterite — sequential."),
("Ella _____ temprano cada mañana. (levantarse)", "se levantaba", "se levantó", "Imperfect — habitual."),
("¿Qué _____ cuando te llamé? (hacer, tú)", "hacías", "hiciste", "Imperfect — in progress when interrupted."),
("¿Qué _____ ayer después de clase? (hacer, tú)", "hiciste", "hacías", "Preterite — completed action."),
("El perro _____ todo el día. (ladrar)", "ladró", "ladraba", "Could be both — preterite bounds the whole day."),
("El perro _____ cuando llegó el cartero. (ladrar)", "ladraba", "ladró", "Imperfect — background action."),
("Yo _____ mucho en esa época. (trabajar)", "trabajaba", "trabajé", "Imperfect — ongoing past period."),
("Yo _____ allí por cinco años. (trabajar)", "trabajé", "trabajaba", "Preterite — completed bounded duration."),
("La tienda _____ a las nueve. (abrir)", "abrió", "abría", "Preterite — one-time event."),
("La tienda _____ a las nueve todos los días. (abrir)", "abría", "abrió", "Imperfect — habitual."),
("Él _____ el periódico cada mañana. (leer)", "leía", "leyó", "Imperfect — habitual."),
("Él _____ el periódico y luego desayunó. (leer)", "leyó", "leía", "Preterite — sequential."),
("_____ una noche oscura y fría. (ser)", "Era", "Fue", "Imperfect — scene setting."),
("_____ un día memorable. (ser)", "Fue", "Era", "Preterite — judgment about completed day."),
("Yo no _____ nada. (decir)", "dije", "decía", "Preterite — single action."),
("Ella siempre _____ la verdad. (decir)", "decía", "dijo", "Imperfect — habitual."),
("Los niños _____ en el jardín. (jugar)", "jugaban", "jugaron", "Imperfect — ongoing scene."),
("Los niños _____ toda la tarde. (jugar)", "jugaron", "jugaban", "Preterite — bounded duration."),
("Cuando _____ niño, mi padre me leía cuentos. (ser, yo)", "era", "fui", "Imperfect — background."),
("Él _____ presidente por ocho años. (ser)", "fue", "era", "Preterite — bounded duration."),
("_____ las seis de la mañana cuando desperté. (ser)", "Eran", "Fueron", "Imperfect — time."),
("_____ un buen año para la empresa. (ser)", "Fue", "Era", "Preterite — completed period judged."),
("Ella _____ triste cuando recibió la noticia. (ponerse)", "se puso", "se ponía", "Preterite — became (change of state)."),
("Ella _____ triste cada vez que llovía. (ponerse)", "se ponía", "se puso", "Imperfect — habitual reaction."),
("Yo _____ poder ir, pero no pude. (querer)", "quería", "quise", "Imperfect — wanted (ongoing desire)."),
("Él no _____ hacerlo. (querer)", "quiso", "quería", "Preterite — refused (completed decision)."),
("Nosotros _____ a la playa el domingo. (ir)", "fuimos", "íbamos", "Preterite — specific completed trip."),
("_____ a la playa cuando empezó a llover. (ir, nosotros)", "Íbamos", "Fuimos", "Imperfect — were going (interrupted)."),
("Ella _____ muy contenta en su nuevo trabajo. (estar)", "estaba", "estuvo", "Imperfect — ongoing state."),
("Ella _____ enferma toda la semana. (estar)", "estuvo", "estaba", "Preterite — bounded duration."),
("Mi abuelo _____ cuentos increíbles. (contar)", "contaba", "contó", "Imperfect — used to tell."),
("Esa vez mi abuelo nos _____ una historia de miedo. (contar)", "contó", "contaba", "Preterite — specific occasion."),
("Yo _____ en el sofá cuando oí un ruido. (estar)", "estaba", "estuve", "Imperfect — background when interrupted."),
("Ella _____ rápidamente y llamó al médico. (vestirse)", "se vistió", "se vestía", "Preterite — sequential."),
("A menudo _____ por el bosque. (caminar, nosotros)", "caminábamos", "caminamos", "Imperfect — habitual (a menudo)."),
("Esa tarde _____ por el bosque. (caminar, nosotros)", "caminamos", "caminábamos", "Preterite — specific occasion."),
("La profesora _____ muy estricta. (ser)", "era", "fue", "Imperfect — description."),
("La profesora _____ muy amable con nosotros ese día. (ser)", "fue", "era", "Preterite — specific day."),
("¿_____ mucho en tu ciudad natal? (llover)", "Llovía", "Llovió", "Imperfect — general weather pattern."),
("¿_____ ayer? (llover)", "Llovió", "Llovía", "Preterite — specific day."),
("El niño _____ porque tenía hambre. (llorar)", "lloraba", "lloró", "Imperfect — ongoing due to reason."),
("El niño _____ cuando se cayó. (llorar)", "lloró", "lloraba", "Preterite — reaction to event."),
("Yo _____ cocinar cuando era joven. (no saber)", "no sabía", "no supe", "Imperfect — ongoing lack."),
("Yo _____ cocinar hasta que tomé clases. (no saber)", "no supe", "no sabía", "Preterite — realized/found out."),
("Ella _____ la carta y empezó a llorar. (leer)", "leyó", "leía", "Preterite — completed then next action."),
("Él _____ cuando entré. (hablar)", "hablaba", "habló", "Imperfect — was speaking (interrupted)."),
("Nosotros _____ en ese restaurante muchas veces. (cenar)", "cenábamos", "cenamos", "Imperfect — habitual."),
("Nosotros _____ en ese restaurante anoche. (cenar)", "cenamos", "cenábamos", "Preterite — specific night."),
("_____ un día perfecto para ir a la playa. (ser)", "Era", "Fue", "Imperfect — description/setting."),
("Ella _____ la primera en llegar. (ser)", "fue", "era", "Preterite — completed fact."),
("Yo _____ en silencio mientras él hablaba. (escuchar)", "escuchaba", "escuché", "Imperfect — simultaneous."),
("Yo _____ todo su discurso. (escuchar)", "escuché", "escuchaba", "Preterite — listened to completion."),
("El tren _____ a las tres en punto. (salir)", "salió", "salía", "Preterite — specific departure."),
("El tren _____ a las tres todos los días. (salir)", "salía", "salió", "Imperfect — habitual schedule."),
("Ella _____ el piano maravillosamente. (tocar)", "tocaba", "tocó", "Imperfect — ability description."),
]
return data.enumerated().map { i, d in
GrammarExercise(id: "pi\(i+1)", prompt: "Choose the correct tense:", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
}
}()
// MARK: - Subjunctive Triggers (100)
private static let subjunctiveTriggerExercises: [GrammarExercise] = {
let data: [(String, String, String, String)] = [
("Quiero que _____ a la fiesta. (venir, tú)", "vengas", "vienes", "Subjunctive — querer (wish)."),
("Es necesario que _____ más. (estudiar, tú)", "estudies", "estudias", "Subjunctive — impersonal expression."),
("Sé que ella _____ aquí. (estar)", "está", "esté", "Indicative — saber (certainty)."),
("Me alegra que _____ aquí. (estar, tú)", "estés", "estás", "Subjunctive — emotion (alegrarse)."),
("Dudo que _____ la verdad. (decir, él)", "diga", "dice", "Subjunctive — doubt (dudar)."),
("Es posible que _____ mañana. (llover)", "llueva", "llueve", "Subjunctive — possibility."),
("Espero que _____ bien. (estar, tú)", "estés", "estás", "Subjunctive — hope (esperar)."),
("Creo que _____ razón. (tener, tú)", "tienes", "tengas", "Indicative — creer (belief)."),
("No creo que _____ razón. (tener, tú)", "tengas", "tienes", "Subjunctive — negated belief."),
("Es importante que _____ puntual. (ser, tú)", "seas", "eres", "Subjunctive — impersonal expression."),
("Ojalá que _____ buen tiempo. (hacer)", "haga", "hace", "Subjunctive — ojalá (wish)."),
("Te pido que _____ silencio. (guardar)", "guardes", "guardas", "Subjunctive — pedir (request)."),
("Es cierto que _____ mucho. (trabajar, ella)", "trabaja", "trabaje", "Indicative — es cierto (certainty)."),
("No es cierto que _____ mucho. (trabajar, ella)", "trabaje", "trabaja", "Subjunctive — negated certainty."),
("Prefiero que _____ tú. (conducir)", "conduzcas", "conduces", "Subjunctive — preferir (preference)."),
("Siento que no _____ venir. (poder, tú)", "puedas", "puedes", "Subjunctive — sentir (emotion)."),
("Es obvio que _____ cansado. (estar, él)", "está", "esté", "Indicative — es obvio (certainty)."),
("Necesito que me _____ un favor. (hacer, tú)", "hagas", "haces", "Subjunctive — necesitar que."),
("Es mejor que _____ temprano. (salir, nosotros)", "salgamos", "salimos", "Subjunctive — es mejor que."),
("Estoy seguro de que _____ bien. (ir, todo)", "va", "vaya", "Indicative — estar seguro (certainty)."),
("Temo que _____ demasiado tarde. (ser)", "sea", "es", "Subjunctive — temer (fear)."),
("Sugiero que _____ más agua. (beber, tú)", "bebas", "bebes", "Subjunctive — sugerir (suggestion)."),
("Es verdad que _____ difícil. (ser)", "es", "sea", "Indicative — es verdad (truth)."),
("No es verdad que _____ difícil. (ser)", "sea", "es", "Subjunctive — negated truth."),
("Quiero que _____ la puerta. (cerrar, tú)", "cierres", "cierras", "Subjunctive — querer."),
("Deseo que _____ feliz. (ser, tú)", "seas", "eres", "Subjunctive — desear (wish)."),
("Es probable que _____ tarde. (llegar, ellos)", "lleguen", "llegan", "Subjunctive — es probable."),
("Es improbable que _____ hoy. (nevar)", "nieve", "nieva", "Subjunctive — es improbable."),
("Me molesta que _____ tanto ruido. (hacer, ellos)", "hagan", "hacen", "Subjunctive — emotion (molestar)."),
("Es evidente que _____ talento. (tener, ella)", "tiene", "tenga", "Indicative — es evidente."),
("Recomiendo que _____ este libro. (leer, tú)", "leas", "lees", "Subjunctive — recomendar."),
("Exijo que _____ a tiempo. (llegar, todos)", "lleguen", "llegan", "Subjunctive — exigir (demand)."),
("Es una lástima que no _____ ir. (poder, tú)", "puedas", "puedes", "Subjunctive — es una lástima."),
("Me sorprende que _____ tan joven. (ser, él)", "sea", "es", "Subjunctive — surprise (emotion)."),
("Insisto en que _____ la verdad. (decir, tú)", "digas", "dices", "Subjunctive — insistir."),
("Es extraño que no _____ aquí. (estar, ella)", "esté", "está", "Subjunctive — es extraño."),
("Prohíbo que _____ en clase. (comer, ustedes)", "coman", "comen", "Subjunctive — prohibir."),
("Permito que _____ temprano. (salir, tú)", "salgas", "sales", "Subjunctive — permitir."),
("Es dudoso que _____ a tiempo. (terminar, nosotros)", "terminemos", "terminamos", "Subjunctive — es dudoso."),
("Pienso que _____ inteligente. (ser, ella)", "es", "sea", "Indicative — pensar (opinion)."),
("No pienso que _____ justo. (ser)", "sea", "es", "Subjunctive — negated opinion."),
("Me encanta que _____ español. (hablar, tú)", "hables", "hablas", "Subjunctive — emotion (encantar)."),
("Es fantástico que _____ aquí. (estar, ustedes)", "estén", "están", "Subjunctive — es fantástico."),
("Mando que _____ inmediatamente. (venir, tú)", "vengas", "vienes", "Subjunctive — mandar."),
("Es ridículo que _____ eso. (pensar, él)", "piense", "piensa", "Subjunctive — es ridículo."),
("Busco a alguien que _____ francés. (hablar)", "hable", "habla", "Subjunctive — nonexistent antecedent."),
("Conozco a alguien que _____ francés. (hablar)", "habla", "hable", "Indicative — known antecedent."),
("No hay nadie que _____ eso. (saber)", "sepa", "sabe", "Subjunctive — negative antecedent."),
("Cuando _____ a casa, llámame. (llegar, tú)", "llegues", "llegas", "Subjunctive — cuando + future."),
("Cuando _____ a casa, siempre como. (llegar, yo)", "llego", "llegue", "Indicative — cuando + habitual."),
("Antes de que _____, quiero decirte algo. (ir, tú)", "te vayas", "te vas", "Subjunctive — antes de que."),
("Después de que _____, descansaremos. (terminar, nosotros)", "terminemos", "terminamos", "Subjunctive — después de que + future."),
("Aunque _____ mucho, iré. (llover)", "llueva", "llueve", "Subjunctive — aunque + hypothetical."),
("Aunque _____ mucho, siempre voy. (llover)", "llueve", "llueva", "Indicative — aunque + factual."),
("Para que _____ bien, debes practicar. (salir, todo)", "salga", "sale", "Subjunctive — para que."),
("Sin que nadie lo _____. (saber)", "sepa", "sabe", "Subjunctive — sin que."),
("Con tal de que _____ contento. (estar, tú)", "estés", "estás", "Subjunctive — con tal de que."),
("A menos que _____ temprano, perderás el tren. (salir, tú)", "salgas", "sales", "Subjunctive — a menos que."),
("En caso de que _____, llámame. (necesitar, tú)", "necesites", "necesitas", "Subjunctive — en caso de que."),
("Mientras _____ aquí, todo estará bien. (estar, yo)", "esté", "estoy", "Subjunctive — mientras + uncertainty."),
("Tan pronto como _____, empezamos. (llegar, él)", "llegue", "llega", "Subjunctive — tan pronto como + future."),
("Hasta que no _____, no me voy. (terminar, tú)", "termines", "terminas", "Subjunctive — hasta que + future."),
("Es hora de que _____ la verdad. (saber, tú)", "sepas", "sabes", "Subjunctive — es hora de que."),
("Espero que _____ un buen día. (tener, tú)", "tengas", "tienes", "Subjunctive — esperar."),
("Dile que _____ aquí. (venir)", "venga", "viene", "Subjunctive — indirect command."),
("No hay nada que _____ hacer. (poder, yo)", "pueda", "puedo", "Subjunctive — negative existence."),
("Es normal que _____ nervioso. (estar, tú)", "estés", "estás", "Subjunctive — es normal que."),
("Me da miedo que _____ sola. (ir, ella)", "vaya", "va", "Subjunctive — emotion (dar miedo)."),
("Es urgente que _____ al doctor. (ir, tú)", "vayas", "vas", "Subjunctive — es urgente."),
("No quiero que _____ tarde. (llegar, tú)", "llegues", "llegas", "Subjunctive — no querer."),
("Tal vez _____ razón. (tener, tú)", "tengas", "tienes", "Subjunctive — tal vez."),
("Quizás _____ mañana. (venir, ella)", "venga", "viene", "Subjunctive — quizás."),
("Es imposible que _____ tan rápido. (terminar, él)", "termine", "termina", "Subjunctive — es imposible."),
("Parece que _____ contento. (estar, él)", "está", "esté", "Indicative — parece que (appears)."),
("No parece que _____ contento. (estar, él)", "esté", "está", "Subjunctive — negated parece."),
("Dice que _____ mañana. (venir)", "viene", "venga", "Indicative — decir reporting fact."),
("Dice que _____ mañana. (venir — as command)", "venga", "viene", "Subjunctive — decir as command."),
("Me preocupa que no _____ bien. (sentirse, tú)", "te sientas", "te sientes", "Subjunctive — emotion (preocupar)."),
("Es raro que _____ tanto calor. (hacer)", "haga", "hace", "Subjunctive — es raro."),
("Confío en que _____ bien. (salir, todo)", "salga", "sale", "Subjunctive — confiar en que."),
("Es fundamental que _____ la tarea. (hacer, ustedes)", "hagan", "hacen", "Subjunctive — es fundamental."),
("Me pone triste que _____ así. (ser, las cosas)", "sean", "son", "Subjunctive — emotion."),
("Aconsejo que _____ más temprano. (acostarse, tú)", "te acuestes", "te acuestas", "Subjunctive — aconsejar."),
("Es bueno que _____ ejercicio. (hacer, tú)", "hagas", "haces", "Subjunctive — es bueno que."),
("Es malo que _____ tanto. (fumar, él)", "fume", "fuma", "Subjunctive — es malo que."),
("Me gusta que _____ aquí. (estar, tú)", "estés", "estás", "Subjunctive — emotion (gustar que)."),
("No creo que _____ la respuesta. (saber, él)", "sepa", "sabe", "Subjunctive — negated belief."),
("Es increíble que _____ tan rápido. (aprender, ella)", "aprenda", "aprende", "Subjunctive — es increíble."),
("Ojala _____ más tiempo. (tener, nosotros)", "tengamos", "tenemos", "Subjunctive — ojalá."),
("Niego que _____ la verdad. (ser, eso)", "sea", "es", "Subjunctive — negar (deny)."),
("Es preciso que _____ ahora. (salir, nosotros)", "salgamos", "salimos", "Subjunctive — es preciso."),
("Te aconsejo que _____ paciencia. (tener)", "tengas", "tienes", "Subjunctive — aconsejar."),
("Basta que _____ una vez. (decir, tú)", "digas", "dices", "Subjunctive — bastar que."),
("Conviene que _____ preparado. (estar, tú)", "estés", "estás", "Subjunctive — convenir que."),
("Es natural que _____ preocupado. (estar, él)", "esté", "está", "Subjunctive — es natural."),
("Ruego que me _____. (perdonar, tú)", "perdones", "perdonas", "Subjunctive — rogar."),
("Es suficiente que _____ una carta. (escribir, tú)", "escribas", "escribes", "Subjunctive — es suficiente que."),
("Me fascina que _____ tantos idiomas. (hablar, ella)", "hable", "habla", "Subjunctive — emotion (fascinar)."),
("Hace falta que _____ más esfuerzo. (poner, nosotros)", "pongamos", "ponemos", "Subjunctive — hacer falta que."),
]
return data.enumerated().map { i, d in
GrammarExercise(id: "st\(i+1)", prompt: "Subjunctive or indicative?", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
}
}()
// MARK: - Personal A (100)
private static let personalAExercises: [GrammarExercise] = {
let data: [(String, String, String, String)] = [
("Veo _____ María.", "a", "(nothing)", "Personal a — specific person as direct object."),
("Veo _____ la mesa.", "(nothing)", "a", "No personal a — thing, not person."),
("Tengo _____ dos hermanos.", "(nothing)", "a", "No personal a after tener."),
("Conozco _____ tu profesor.", "a", "(nothing)", "Personal a — specific person."),
("Busco _____ un doctor.", "(nothing)", "a", "No personal a — non-specific person."),
("No veo _____ nadie.", "a", "(nothing)", "Personal a with nadie."),
("Llamo _____ mi madre.", "a", "(nothing)", "Personal a — specific person."),
("Extraño _____ mis amigos.", "a", "(nothing)", "Personal a — specific people."),
("Necesito _____ un traductor.", "(nothing)", "a", "No personal a — any translator."),
("Necesito _____ mi traductor.", "a", "(nothing)", "Personal a — specific person."),
("¿Conoces _____ alguien aquí?", "a", "(nothing)", "Personal a with alguien."),
("¿_____ quién llamaste?", "A", "(nothing)", "Personal a with quién."),
("Invité _____ Juan a la fiesta.", "a", "(nothing)", "Personal a — specific person."),
("Compré _____ un libro.", "(nothing)", "a", "No personal a — thing."),
("Llevo _____ mi perro al veterinario.", "a", "(nothing)", "Personal a — beloved pet."),
("Quiero _____ mi familia.", "a", "(nothing)", "Personal a — loving people."),
("Leo _____ un libro.", "(nothing)", "a", "No personal a — thing."),
("Escucho _____ mi profesora.", "a", "(nothing)", "Personal a — specific person."),
("Escucho _____ música.", "(nothing)", "a", "No personal a — thing."),
("Busco _____ mi hija.", "a", "(nothing)", "Personal a — specific person."),
("Busco _____ mis llaves.", "(nothing)", "a", "No personal a — things."),
("Vi _____ Carlos en el parque.", "a", "(nothing)", "Personal a — specific person."),
("Vi _____ una película.", "(nothing)", "a", "No personal a — thing."),
("Admiro _____ esa mujer.", "a", "(nothing)", "Personal a — specific person."),
("Tiene _____ tres hijos.", "(nothing)", "a", "No personal a after tener."),
("Ayudo _____ mi vecina.", "a", "(nothing)", "Personal a — specific person."),
("Encontré _____ Pedro en la tienda.", "a", "(nothing)", "Personal a — specific person."),
("Encontré _____ un buen restaurante.", "(nothing)", "a", "No personal a — thing/place."),
("Esperamos _____ nuestros padres.", "a", "(nothing)", "Personal a — specific people."),
("Esperamos _____ el autobús.", "(nothing)", "a", "No personal a — thing."),
("Odio _____ la violencia.", "(nothing)", "a", "No personal a — abstract concept."),
("Odio _____ ese hombre.", "a", "(nothing)", "Personal a — specific person."),
("Contrataron _____ un ingeniero.", "(nothing)", "a", "No personal a — non-specific person."),
("Contrataron _____ María.", "a", "(nothing)", "Personal a — specific person."),
("Cuido _____ mis hijos.", "a", "(nothing)", "Personal a — caring for people."),
("Cuido _____ mi jardín.", "(nothing)", "a", "No personal a — thing."),
("Respeto _____ mis abuelos.", "a", "(nothing)", "Personal a — specific people."),
("Respeto _____ las reglas.", "(nothing)", "a", "No personal a — things."),
("Visité _____ mi tía.", "a", "(nothing)", "Personal a — specific person."),
("Visité _____ el museo.", "(nothing)", "a", "No personal a — place."),
("Abandonó _____ su familia.", "a", "(nothing)", "Personal a — people."),
("Abandonó _____ su coche.", "(nothing)", "a", "No personal a — thing."),
("Presenté _____ mi novio.", "a", "(nothing)", "Personal a — specific person."),
("Traje _____ mi hermano.", "a", "(nothing)", "Personal a — specific person."),
("Traje _____ comida.", "(nothing)", "a", "No personal a — thing."),
("Echamos de menos _____ nuestros amigos.", "a", "(nothing)", "Personal a — missing people."),
("Mandé _____ los niños al colegio.", "a", "(nothing)", "Personal a — sending people."),
("Mandé _____ una carta.", "(nothing)", "a", "No personal a — thing."),
("Saludé _____ la vecina.", "a", "(nothing)", "Personal a — specific person."),
("Abrí _____ la puerta.", "(nothing)", "a", "No personal a — thing."),
("Elegimos _____ un nuevo líder.", "a", "(nothing)", "Personal a — specific person elected."),
("Elegimos _____ un buen restaurante.", "(nothing)", "a", "No personal a — thing."),
("Acusaron _____ el sospechoso.", "a", "(nothing)", "Personal a — specific person."),
("Derribaron _____ el edificio.", "(nothing)", "a", "No personal a — thing."),
("Recogí _____ los niños del colegio.", "a", "(nothing)", "Personal a — picking up people."),
("Recogí _____ mis cosas.", "(nothing)", "a", "No personal a — things."),
("Críticaron _____ el presidente.", "a", "(nothing)", "Personal a — specific person."),
("Critícaron _____ la decisión.", "(nothing)", "a", "No personal a — thing."),
("Perdoné _____ mi amigo.", "a", "(nothing)", "Personal a — specific person."),
("Perdoné _____ su error.", "(nothing)", "a", "No personal a — thing."),
("Describió _____ su madre.", "a", "(nothing)", "Personal a — specific person."),
("Describió _____ la situación.", "(nothing)", "a", "No personal a — thing."),
("Abracé _____ mi abuela.", "a", "(nothing)", "Personal a — specific person."),
("Obedezco _____ mis padres.", "a", "(nothing)", "Personal a — people."),
("Obedezco _____ las leyes.", "(nothing)", "a", "No personal a — things."),
("Felicité _____ mi compañero.", "a", "(nothing)", "Personal a — specific person."),
("Cuidamos _____ nuestro gato.", "a", "(nothing)", "Personal a — beloved pet."),
("Cuidamos _____ la casa.", "(nothing)", "a", "No personal a — thing."),
("Castigaron _____ los culpables.", "a", "(nothing)", "Personal a — specific people."),
("Repararon _____ el techo.", "(nothing)", "a", "No personal a — thing."),
("Defendí _____ mi hermana.", "a", "(nothing)", "Personal a — specific person."),
("Defendí _____ mi posición.", "(nothing)", "a", "No personal a — abstract."),
("Acompañé _____ mi amiga al aeropuerto.", "a", "(nothing)", "Personal a — specific person."),
("Ignoré _____ el comentario.", "(nothing)", "a", "No personal a — thing."),
("Ignoré _____ esa persona.", "a", "(nothing)", "Personal a — specific person."),
("Reconocí _____ Juan inmediatamente.", "a", "(nothing)", "Personal a — specific person."),
("Reconocí _____ la canción.", "(nothing)", "a", "No personal a — thing."),
("Salvaron _____ los pasajeros.", "a", "(nothing)", "Personal a — people."),
("Salvaron _____ los documentos.", "(nothing)", "a", "No personal a — things."),
("Atendemos _____ nuestros clientes.", "a", "(nothing)", "Personal a — people."),
("Atendemos _____ los pedidos.", "(nothing)", "a", "No personal a — things."),
("Despidieron _____ tres empleados.", "a", "(nothing)", "Personal a — people."),
("Pintaron _____ la casa.", "(nothing)", "a", "No personal a — thing."),
("Enseño _____ mis estudiantes.", "a", "(nothing)", "Personal a — people."),
("Enseño _____ español.", "(nothing)", "a", "No personal a — subject/thing."),
("Protegemos _____ los niños.", "a", "(nothing)", "Personal a — people."),
("Protegemos _____ el medio ambiente.", "(nothing)", "a", "No personal a — thing."),
("Entrevisté _____ la candidata.", "a", "(nothing)", "Personal a — specific person."),
("Preparé _____ la cena.", "(nothing)", "a", "No personal a — thing."),
("Culparon _____ los responsables.", "a", "(nothing)", "Personal a — people."),
("Cerraron _____ la tienda.", "(nothing)", "a", "No personal a — thing."),
("Seguí _____ el ladrón.", "a", "(nothing)", "Personal a — specific person."),
("Seguí _____ las instrucciones.", "(nothing)", "a", "No personal a — things."),
("Engañaron _____ los clientes.", "a", "(nothing)", "Personal a — people."),
("Rompieron _____ la ventana.", "(nothing)", "a", "No personal a — thing."),
("Consulté _____ un especialista.", "a", "(nothing)", "Personal a — specific person."),
("Consulté _____ un diccionario.", "(nothing)", "a", "No personal a — thing."),
("Persiguieron _____ los criminales.", "a", "(nothing)", "Personal a — people."),
("Lavé _____ el coche.", "(nothing)", "a", "No personal a — thing."),
("Detuvieron _____ los manifestantes.", "a", "(nothing)", "Personal a — people."),
]
return data.enumerated().map { i, d in
GrammarExercise(id: "pa\(i+1)", prompt: "Is the personal 'a' needed?", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
}
}()
}

View File

@@ -28,6 +28,9 @@ struct GrammarNote: Identifiable {
accentMarksStress,
seConstructions,
estarGerundProgressive,
spanishSuffixes,
commonIrregularVerbs,
typesOfIrregularVerbs,
]
// MARK: - 1. Ser vs Estar
@@ -887,4 +890,388 @@ struct GrammarNote: Identifiable {
**Other verbs with gerunds:** While *estar* is the most common, other verbs can also combine with gerunds: *seguir* (to keep on), *continuar* (to continue), *andar* (to go around), *llevar* (duration). *Sigo estudiando español.* — I keep on studying Spanish. *Llevo tres años viviendo aquí.* — I've been living here for three years.
"""
)
// MARK: - 21. Spanish Suffixes
private static let spanishSuffixes = GrammarNote(
id: "spanish-suffixes",
title: "Spanish Suffixes",
category: "Word Building",
body: """
Spanish uses suffixes — endings added to root words — to change meaning, size, tone, or part of speech. Learning these patterns lets you decode unfamiliar words and build new ones from roots you already know.
**Diminutives — smallness and affection**
Diminutive suffixes make a noun feel smaller, cuter, or more endearing. They are extremely common in everyday speech.
*-ito / -ita* — the most common diminutive. Expresses smallness or affection.
*perro → perrito* — little dog
*casa → casita* — little house
*momento → momentito* — just a moment
*abuela → abuelita* — grandma (affectionate)
*-cito / -cita* — used after words ending in a consonant or -e.
*café → cafecito* — a nice little coffee
*pobre → pobrecito* — poor little thing
*amor → amorcito* — sweetheart
*-illo / -illa* — a slightly less affectionate diminutive.
*chico → chiquillo* — little kid
*guerra → guerrilla* — small war / guerrilla
*pan → panecillo* — bread roll
*-ico / -ica* — regional diminutive popular in Colombia, Cuba, and Costa Rica.
*rato → ratico* — a little while
*gato → gatico* — little cat
**Augmentatives — bigness and intensity**
Augmentative suffixes make nouns feel bigger, more intense, or sometimes clumsier. They often carry a slightly negative or humorous tone.
*-ón / -ona* — big or intense, sometimes negative.
*silla → sillón* — armchair (big chair)
*soltar → solterón* — confirmed bachelor
*cabeza → cabezón* — big-headed / stubborn
*-ote / -ota* — big, often clumsy or exaggerated.
*grande → grandote* — really big
*amigo → amigote* — a big buddy (casual)
*palabra → palabrota* — a swear word (a "big word")
*-azo / -aza* — big; also means a blow or hit with something.
*perro → perrazo* — huge dog
*puño → puñetazo* — a punch
*gol → golazo* — an amazing goal
*codo → codazo* — an elbow jab
*-udo / -uda* — having a lot of something.
*pelo → peludo* — hairy
*barba → barbudo* — bearded
*panza → panzudo* — big-bellied
**Verb-related endings**
These endings are built into the verb conjugation system and tell you when and how the action occurs.
*-ando* — gerund for -ar verbs (happening now, equivalent to English "-ing").
*hablar → hablando* — speaking
*caminar → caminando* — walking
*estudiar → estudiando* — studying
*-iendo* — gerund for -er and -ir verbs.
*comer → comiendo* — eating
*vivir → viviendo* — living
*leer → leyendo* — reading (note the spelling change)
*-ado* — past participle for -ar verbs (equivalent to English "-ed").
*hablar → hablado* — spoken
*cerrar → cerrado* — closed
*cansar → cansado* — tired
*-ido* — past participle for -er and -ir verbs.
*comer → comido* — eaten
*vivir → vivido* — lived
*dormir → dormido* — slept
**Noun-forming suffixes**
These suffixes turn verbs or adjectives into nouns, creating words for actions, results, places, and people.
*-ción / -sión* — action or result, equivalent to English "-tion."
*actuar → acción* — action
*decidir → decisión* — decision
*comunicar → comunicación* — communication
*expresar → expresión* — expression
*-miento* — action or result, similar to -ción.
*conocer → conocimiento* — knowledge
*sentir → sentimiento* — feeling
*mover → movimiento* — movement
*-ería* — a shop, business, or place associated with something.
*pan → panadería* — bakery
*libro → librería* — bookstore
*zapato → zapatería* — shoe store
*carne → carnicería* — butcher shop
*-ero / -era* — a person who does or makes something.
*pan → panadero* — baker
*cocina → cocinero* — cook
*carta → cartero* — mail carrier
*enfermo → enfermera* — nurse
*-dor / -dora* — a person or thing that does something.
*jugar → jugador* — player
*trabajar → trabajador* — worker
*computar → computadora* — computer
*lavar → lavadora* — washing machine
*-ista* — a person devoted to something (gender-neutral form).
*arte → artista* — artist
*diente → dentista* — dentist
*fútbol → futbolista* — soccer player
*piano → pianista* — pianist
*-ura* — a quality or result.
*dulce → dulzura* — sweetness
*loco → locura* — madness
*abrir → abertura* — opening
*pintar → pintura* — painting / paint
*-eza* — an abstract quality.
*bello → belleza* — beauty
*triste → tristeza* — sadness
*puro → pureza* — purity
*natural → naturaleza* — nature
*-dad / -tad* — a quality, equivalent to English "-ty."
*libre → libertad* — liberty
*real → realidad* — reality
*feliz → felicidad* — happiness
*amigo → amistad* — friendship
*-anza* — action or result.
*esperar → esperanza* — hope
*enseñar → enseñanza* — teaching
*confiar → confianza* — trust / confidence
**Adjective-forming suffixes**
These turn nouns or verbs into adjectives that describe qualities, origins, or tendencies.
*-oso / -osa* — full of something, equivalent to English "-ous."
*peligro → peligroso* — dangerous
*hermosura → hermoso* — beautiful
*cariño → cariñoso* — affectionate
*éxito → exitoso* — successful
*-ano / -ana* — relating to a place or group.
*México → mexicano* — Mexican
*América → americano* — American
*cristiano* — Christian
*humano* — human
*-eño / -eña* — from a place.
*Panamá → panameño* — Panamanian
*Brasil → brasileño* — Brazilian
*isla → isleño* — islander
*-ense* — from a place (often countries or cities).
*Costa Rica → costarricense* — Costa Rican
*Estados Unidos → estadounidense* — American (USA)
*Canadá → canadiense* — Canadian
*-ble* — able to be, equivalent to English "-ble."
*posible* — possible
*increíble* — incredible
*responsable* — responsible
*agradable* — pleasant
*-ivo / -iva* — tending toward, equivalent to English "-ive."
*actuar → activo* — active
*crear → creativo* — creative
*producir → productivo* — productive
**Adverb and intensity suffixes**
*-mente* — turns adjectives into adverbs, equivalent to English "-ly." Attach it to the feminine form of the adjective.
*rápida → rápidamente* — quickly
*fácil → fácilmente* — easily
*tranquila → tranquilamente* — calmly
*completa → completamente* — completely
*-ísimo / -ísima* — absolute superlative, meaning "extremely" or "super."
*rápido → rapidísimo* — super fast
*bella → bellísima* — extremely beautiful
*mucho → muchísimo* — very very much
*grande → grandísimo* — enormous
**Pejorative suffixes — negative or ugly tone**
These suffixes add a negative, derogatory, or ugly connotation.
*-ucho / -ucha* — ugly, run-down.
*casa → casucha* — a shack, a dump
*delgado → delgaducho* — scrawny
*-aco / -aca* — ugly, derogatory.
*pájaro → pajarraco* — ugly bird
*libro → libraco* — a terrible book
**Key insight:** Once you recognize root + suffix patterns, you can decode unfamiliar words. For example, *panadería* = *pan* (bread) + *-ero* (person) + *-ía* (place) = "place where the bread-person works" = bakery. The suffix system makes Spanish vocabulary highly predictable and composable.
"""
)
// MARK: - 22. Most Common Irregular Verbs
private static let commonIrregularVerbs = GrammarNote(
id: "common-irregular-verbs",
title: "Most Common Irregular Verbs",
category: "Irregular Verbs",
body: """
These 15 verbs are among the most used in Spanish — and they are all irregular. Mastering their forms is essential because you will encounter them in nearly every conversation.
**ser** — to be (permanent)
*yo soy, tú eres, él es* — Present
*yo fui, tú fuiste, él fue* — Preterite
*Soy estudiante.* — I am a student.
**estar** — to be (temporary/location)
*yo estoy, tú estás, él está* — Present
*yo estuve, tú estuviste, él estuvo* — Preterite
*Estoy cansado.* — I am tired.
**ir** — to go
*yo voy, tú vas, él va* — Present
*yo fui, tú fuiste, él fue* — Preterite (same as ser!)
*Voy al supermercado.* — I'm going to the supermarket.
**haber** — to have (auxiliary)
*yo he, tú has, él ha* — Present
*yo hube, tú hubiste, él hubo* — Preterite
*He comido ya.* — I have already eaten.
**tener** — to have (possession)
*yo tengo, tú tienes, él tiene* — Present
*yo tuve, tú tuviste, él tuvo* — Preterite
*Tengo dos hermanos.* — I have two siblings.
**hacer** — to do / to make
*yo hago, tú haces, él hace* — Present
*yo hice, tú hiciste, él hizo* — Preterite
*¿Qué haces?* — What are you doing?
**poder** — to be able to / can
*yo puedo, tú puedes, él puede* — Present
*yo pude, tú pudiste, él pudo* — Preterite
*No puedo dormir.* — I can't sleep.
**querer** — to want / to love
*yo quiero, tú quieres, él quiere* — Present
*yo quise, tú quisiste, él quiso* — Preterite
*Quiero agua, por favor.* — I want water, please.
**decir** — to say / to tell
*yo digo, tú dices, él dice* — Present
*yo dije, tú dijiste, él dijo* — Preterite
*¿Qué dices?* — What are you saying?
**venir** — to come
*yo vengo, tú vienes, él viene* — Present
*yo vine, tú viniste, él vino* — Preterite
*Vengo de la tienda.* — I'm coming from the store.
**saber** — to know (facts)
*yo sé, tú sabes, él sabe* — Present
*yo supe, tú supiste, él supo* — Preterite
*No sé la respuesta.* — I don't know the answer.
**poner** — to put / to place
*yo pongo, tú pones, él pone* — Present
*yo puse, tú pusiste, él puso* — Preterite
*Pon el libro en la mesa.* — Put the book on the table.
**salir** — to leave / to go out
*yo salgo, tú sales, él sale* — Present
*yo salí, tú saliste, él salió* — Preterite
*Salgo a las ocho.* — I leave at eight.
**dar** — to give
*yo doy, tú das, él da* — Present
*yo di, tú diste, él dio* — Preterite
*Dame un momento.* — Give me a moment.
**ver** — to see
*yo veo, tú ves, él ve* — Present
*yo vi, tú viste, él vio* — Preterite
*¿Ves eso?* — Do you see that?
**Tip:** These verbs appear so frequently that you will naturally memorize them through practice. Focus on the present and preterite forms first — those are the ones you need every day.
"""
)
// MARK: - 23. Types of Irregular Verbs
private static let typesOfIrregularVerbs = GrammarNote(
id: "types-of-irregular-verbs",
title: "Types of Irregular Verbs",
category: "Irregular Verbs",
body: """
Spanish irregular verbs follow predictable patterns. Once you learn the pattern, you can conjugate dozens of verbs correctly. There are three main types of irregularity.
**Spelling Changes**
These verbs change their spelling to preserve the correct pronunciation. The sound stays the same — only the letters change. This happens because Spanish spelling rules require certain letter combinations before specific vowels.
*c → qu* (before e): buscar → busqué, tocar → toqué, sacar → saqué
*g → gu* (before e): pagar → pagué, llegar → llegué, jugar → jugué
*z → c* (before e): empezar → empecé, almorzar → almorcé, cruzar → crucé
*g → j* (before a/o): coger → cojo, elegir → elijo, proteger → protejo
*gu → gü* (before e): averiguar → averigüé
*i → y* (between vowels): leer → leyó/leyeron, oír → oyó/oyeron
*Busqué mis llaves por toda la casa.* — I looked for my keys all over the house.
*Llegué tarde al trabajo.* — I arrived late to work.
*Empecé a estudiar español el año pasado.* — I started studying Spanish last year.
**Stem Changes**
These verbs change a vowel in the stem when it is stressed. The change only happens in certain persons (usually all except nosotros and vosotros in the present tense). Common patterns:
*e → ie:* pensar → pienso, querer → quiero, preferir → prefiero, cerrar → cierro, entender → entiendo
*Quiero ir al cine.* — I want to go to the movies.
*Prefiero quedarme en casa.* — I prefer to stay at home.
*o → ue:* poder → puedo, dormir → duermo, volver → vuelvo, encontrar → encuentro, recordar → recuerdo
*No puedo encontrar mi teléfono.* — I can't find my phone.
*Duermo ocho horas cada noche.* — I sleep eight hours every night.
*e → i* (only -ir verbs): pedir → pido, servir → sirvo, repetir → repito, seguir → sigo, vestirse → me visto
*Pido una cerveza, por favor.* — I'll have a beer, please.
*Sigo estudiando todos los días.* — I keep studying every day.
*u → ue:* jugar → juego (the only verb with this pattern!)
*Juego al fútbol los sábados.* — I play soccer on Saturdays.
**Unique Irregulars**
These verbs have forms that don't follow any predictable pattern — you simply have to memorize them. The good news is that the most common ones (ser, ir, haber, etc.) are used so frequently that you learn them quickly through exposure.
*ser:* soy, eres, es, somos, sois, son (present) — fui, fuiste, fue... (preterite)
*ir:* voy, vas, va, vamos, vais, van (present) — fui, fuiste, fue... (preterite, same as ser!)
*haber:* he, has, ha, hemos, habéis, han (present auxiliary)
*hacer:* hago (yo present), hice/hizo (preterite)
*decir:* digo (yo present), dije/dijo (preterite)
*tener:* tengo (yo present), tuve (preterite)
*venir:* vengo (yo present), vine (preterite)
**The "go" verbs — yo forms ending in -go:** Many common verbs have an irregular *yo* form in the present tense that ends in *-go*, while all other persons are regular or follow a stem change.
*tener → tengo, poner → pongo, salir → salgo, venir → vengo, hacer → hago, decir → digo, traer → traigo, oír → oigo, caer → caigo*
*Tengo que salir ahora.* — I have to leave now.
*Pongo la mesa antes de cenar.* — I set the table before dinner.
**Tip:** Use the Irregularity Drills in the Practice tab to focus on each type separately — spelling changes, stem changes, or unique irregulars.
"""
)
}

View File

@@ -8,9 +8,16 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case typingEsToEn = "typing_es_to_en"
case handwritingEnToEs = "hw_en_to_es"
case handwritingEsToEn = "hw_es_to_en"
case completeSentenceES = "complete_sentence_es"
case checkpoint = "checkpoint"
var id: String { rawValue }
/// Quiz types shown in the weekly test picker (excludes checkpoint).
static var weeklyQuizTypes: [QuizType] {
allCases.filter { $0 != .checkpoint }
}
var label: String {
switch self {
case .mcEnToEs: "Multiple Choice: EN → ES"
@@ -19,6 +26,8 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case .typingEsToEn: "Fill in the Blank: ES → EN"
case .handwritingEnToEs: "Handwriting: EN → ES"
case .handwritingEsToEn: "Handwriting: ES → EN"
case .completeSentenceES: "Complete the Sentence"
case .checkpoint: "Checkpoint Exam"
}
}
@@ -27,6 +36,8 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case .mcEnToEs, .mcEsToEn: "list.bullet"
case .typingEnToEs, .typingEsToEn: "keyboard"
case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline"
case .completeSentenceES: "text.badge.checkmark"
case .checkpoint: "checkmark.seal"
}
}
@@ -38,6 +49,8 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case .typingEsToEn: "See Spanish, type the English word"
case .handwritingEnToEs: "See English, handwrite the Spanish word"
case .handwritingEsToEn: "See Spanish, handwrite the English word"
case .completeSentenceES: "Read a Spanish sentence and pick the missing word"
case .checkpoint: "Cumulative review of all weeks so far"
}
}
@@ -45,20 +58,20 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
var promptLanguage: String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "Spanish"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: "Spanish"
}
}
var answerLanguage: String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "Spanish"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "English"
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: "Spanish"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: "English"
}
}
var isMultipleChoice: Bool {
switch self {
case .mcEnToEs, .mcEsToEn: true
case .mcEnToEs, .mcEsToEn, .completeSentenceES, .checkpoint: true
case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false
}
}
@@ -70,17 +83,21 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
}
}
var isCompleteSentence: Bool {
self == .completeSentenceES
}
func prompt(for card: VocabCard) -> String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.front
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: card.front
}
}
func answer(for card: VocabCard) -> String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.front
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.back
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: card.front
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: card.back
}
}
}

View File

@@ -29,6 +29,18 @@ enum TenseID: String, CaseIterable, Codable, Sendable, Hashable {
.ind_futuro,
]
/// The 6 most essential tenses every learner should master.
static let coreTenses: Set<TenseID> = [
.ind_presente,
.ind_preterito,
.ind_imperfecto,
.ind_futuro,
.subj_presente,
.imp_afirmativo,
]
static let coreTenseIDs = coreTenses.map(\.rawValue)
static let defaultPracticeIDs = defaultPractice.map(\.rawValue)
}
@@ -39,6 +51,10 @@ struct TenseInfo: Identifiable, Hashable, Sendable {
let mood: String
let order: Int
var isCore: Bool {
TenseID(rawValue: id).map { TenseID.coreTenses.contains($0) } ?? false
}
static let all: [TenseInfo] = [
TenseInfo(id: TenseID.ind_presente.rawValue, spanish: "Indicativo Presente", english: "Present", mood: "Indicative", order: 0),
TenseInfo(id: TenseID.ind_preterito.rawValue, spanish: "Indicativo Pretérito", english: "Preterite", mood: "Indicative", order: 1),

View File

@@ -0,0 +1,78 @@
import Foundation
import FoundationModels
import SharedModels
@MainActor
@Observable
final class ConversationService {
var isResponding = false
private var session: LanguageModelSession?
static let scenarios = [
"Ordering at a restaurant",
"Asking for directions",
"Shopping at a market",
"Checking into a hotel",
"Making plans with a friend",
"At the doctor's office",
"Job interview",
"Renting an apartment",
"At the airport",
"Meeting someone new",
]
func startConversation(scenario: String, level: String) -> String {
session = LanguageModelSession(instructions: """
You are a friendly Spanish conversation partner. The scenario is: \(scenario).
The student's level is: \(level).
Rules:
- Respond ONLY in Spanish appropriate for the student's level.
- Keep responses to 1-3 sentences.
- If the student makes a grammar mistake, gently correct it in parentheses \
at the end of your response, like: (Pequeña corrección: "fuiste" en vez de "fue")
- Stay in character for the scenario.
- Be encouraging and natural.
""")
// Return the opening message based on scenario
switch scenario {
case "Ordering at a restaurant":
return "¡Bienvenido! Soy su mesero. ¿Ya sabe qué le gustaría ordenar?"
case "Asking for directions":
return "¡Hola! ¿En qué le puedo ayudar? ¿Está buscando algún lugar?"
case "Shopping at a market":
return "¡Buenos días! Tenemos frutas muy frescas hoy. ¿Qué le gustaría comprar?"
case "Checking into a hotel":
return "Buenas tardes, bienvenido al Hotel Sol. ¿Tiene una reservación?"
case "Making plans with a friend":
return "¡Oye! ¿Qué quieres hacer este fin de semana? Estoy libre el sábado."
case "At the doctor's office":
return "Buenos días. Soy el doctor García. ¿Cómo se siente hoy? ¿Qué le pasa?"
case "Job interview":
return "Buenos días, gracias por venir. Cuénteme un poco sobre usted."
case "Renting an apartment":
return "¡Hola! Gracias por su interés en el apartamento. ¿Qué preguntas tiene?"
case "At the airport":
return "Buenas tardes, pasajero. ¿Me puede mostrar su pasaporte y su boleto?"
case "Meeting someone new":
return "¡Hola! Me llamo Carlos. ¿Cómo te llamas? ¿De dónde eres?"
default:
return "¡Hola! ¿Cómo estás? Vamos a practicar español juntos."
}
}
func respond(to userMessage: String) async throws -> String {
guard let session else { return "Error: no session" }
isResponding = true
defer { isResponding = false }
let response = try await session.respond(to: userMessage)
return response.content
}
static var isAvailable: Bool {
SystemLanguageModel.default.availability == .available
}
}

View File

@@ -3,6 +3,21 @@ import SharedModels
import Foundation
actor DataLoader {
static let courseDataVersion = 6
static let courseDataKey = "courseDataVersion"
/// Quick check: does the DB need seeding or course data refresh?
static func needsSeeding(container: ModelContainer) async -> Bool {
let context = ModelContext(container)
let verbCount = (try? context.fetchCount(FetchDescriptor<Verb>())) ?? 0
if verbCount == 0 { return true }
let storedVersion = UserDefaults.standard.integer(forKey: courseDataKey)
if storedVersion < courseDataVersion { return true }
return false
}
static func seedIfNeeded(container: ModelContainer) async {
let context = ModelContext(container)
@@ -123,11 +138,9 @@ actor DataLoader {
/// Re-seed course data if the version has changed (e.g. examples were added).
/// Call this on every launch it checks a version key and only re-seeds when needed.
static func refreshCourseDataIfNeeded(container: ModelContainer) async {
let currentVersion = 3 // Bump this whenever course_data.json changes
let key = "courseDataVersion"
let shared = UserDefaults.standard
if shared.integer(forKey: key) >= currentVersion { return }
if shared.integer(forKey: courseDataKey) >= courseDataVersion { return }
print("Course data version outdated — re-seeding...")
let context = ModelContext(container)
@@ -140,8 +153,8 @@ actor DataLoader {
// Re-seed
seedCourseData(context: context)
shared.set(currentVersion, forKey: key)
print("Course data re-seeded to version \(currentVersion)")
shared.set(courseDataVersion, forKey: courseDataKey)
print("Course data re-seeded to version \(courseDataVersion)")
}
static func migrateCourseProgressIfNeeded(
@@ -255,14 +268,18 @@ actor DataLoader {
// Parse example sentences
var exES: [String] = []
var exEN: [String] = []
var exBlanks: [String] = []
if let examples = cardDict["examples"] as? [[String: String]] {
for ex in examples {
if let es = ex["es"] { exES.append(es) }
if let en = ex["en"] { exEN.append(en) }
if let es = ex["es"] {
exES.append(es)
exEN.append(ex["en"] ?? "")
exBlanks.append(ex["blank"] ?? "")
}
}
}
let card = VocabCard(front: front, back: back, deckId: deckId, examplesES: exES, examplesEN: exEN)
let card = VocabCard(front: front, back: back, deckId: deckId, examplesES: exES, examplesEN: exEN, examplesBlanks: exBlanks)
card.deck = deck
context.insert(card)
cardCount += 1

View File

@@ -0,0 +1,268 @@
import Foundation
import SharedModels
import SwiftData
@MainActor
@Observable
final class DictionaryService {
struct Entry {
let word: String
let baseForm: String
let english: String
let partOfSpeech: String
let tenseId: String?
let person: String?
}
private var verbIndex: [String: Entry] = [:]
private var nonVerbIndex: [String: Entry] = [:]
private var isBuilt = false
/// Build the reverse index from existing verb data + bundled non-verb dictionary.
/// Loads from disk cache if available, otherwise builds from DB and caches.
func buildIfNeeded(context: ModelContext) {
guard !isBuilt else { return }
loadNonVerbDictionary()
if loadCachedIndex() {
isBuilt = true
return
}
// No cache build from DB
let verbDescriptor = FetchDescriptor<Verb>()
let verbs = (try? context.fetch(verbDescriptor)) ?? []
let verbMap = Dictionary(uniqueKeysWithValues: verbs.map { ($0.id, $0) })
let formDescriptor = FetchDescriptor<VerbForm>()
let forms = (try? context.fetch(formDescriptor)) ?? []
let persons = TenseInfo.persons
for form in forms {
guard let verb = verbMap[form.verbId] else { continue }
let key = form.form.lowercased()
if verbIndex[key] != nil { continue }
let person = form.personIndex < persons.count ? persons[form.personIndex] : nil
verbIndex[key] = Entry(
word: form.form,
baseForm: verb.infinitive,
english: verb.english,
partOfSpeech: "verb",
tenseId: form.tenseId,
person: person
)
}
for verb in verbs {
let key = verb.infinitive.lowercased()
if verbIndex[key] == nil {
verbIndex[key] = Entry(
word: verb.infinitive,
baseForm: verb.infinitive,
english: verb.english,
partOfSpeech: "verb",
tenseId: nil,
person: nil
)
}
}
isBuilt = true
saveCachedIndex()
print("[Dictionary] Built index from DB: \(verbIndex.count) verb forms")
}
// MARK: - Disk Cache
private static var cacheURL: URL {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("dictionary_index.json")
}
private struct CachedEntry: Codable {
let word: String
let baseForm: String
let english: String
let partOfSpeech: String
let tenseId: String?
let person: String?
}
private func saveCachedIndex() {
let entries = verbIndex.map { (key: $0.key, value: CachedEntry(
word: $0.value.word, baseForm: $0.value.baseForm,
english: $0.value.english, partOfSpeech: $0.value.partOfSpeech,
tenseId: $0.value.tenseId, person: $0.value.person
))}
let dict = Dictionary(uniqueKeysWithValues: entries)
if let data = try? JSONEncoder().encode(dict) {
try? data.write(to: Self.cacheURL)
}
}
private func loadCachedIndex() -> Bool {
guard let data = try? Data(contentsOf: Self.cacheURL),
let dict = try? JSONDecoder().decode([String: CachedEntry].self, from: data) else {
return false
}
verbIndex = dict.mapValues { Entry(
word: $0.word, baseForm: $0.baseForm,
english: $0.english, partOfSpeech: $0.partOfSpeech,
tenseId: $0.tenseId, person: $0.person
)}
print("[Dictionary] Loaded cached index: \(verbIndex.count) verb forms")
return true
}
func lookup(_ word: String) -> Entry? {
let cleaned = word.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
return verbIndex[cleaned] ?? nonVerbIndex[cleaned]
}
private func loadNonVerbDictionary() {
// Common non-verb Spanish words articles, prepositions, pronouns, adjectives, nouns, adverbs, conjunctions
let words: [(String, String, String)] = [
// Articles
("el", "the (masc.)", "article"), ("la", "the (fem.)", "article"),
("los", "the (masc. pl.)", "article"), ("las", "the (fem. pl.)", "article"),
("un", "a, an (masc.)", "article"), ("una", "a, an (fem.)", "article"),
("unos", "some (masc.)", "article"), ("unas", "some (fem.)", "article"),
// Pronouns
("yo", "I", "pronoun"), ("", "you (informal)", "pronoun"),
("él", "he", "pronoun"), ("ella", "she", "pronoun"),
("nosotros", "we (masc.)", "pronoun"), ("nosotras", "we (fem.)", "pronoun"),
("ellos", "they (masc.)", "pronoun"), ("ellas", "they (fem.)", "pronoun"),
("usted", "you (formal)", "pronoun"), ("ustedes", "you all (formal)", "pronoun"),
("me", "me", "pronoun"), ("te", "you (obj.)", "pronoun"),
("nos", "us", "pronoun"), ("le", "him/her/you (obj.)", "pronoun"),
("les", "them/you all (obj.)", "pronoun"), ("lo", "it/him (obj.)", "pronoun"),
("se", "self/each other", "pronoun"), ("mi", "my", "pronoun"),
("tu", "your (informal)", "pronoun"), ("su", "his/her/your/their", "pronoun"),
("nuestro", "our (masc.)", "pronoun"), ("nuestra", "our (fem.)", "pronoun"),
("esto", "this", "pronoun"), ("eso", "that", "pronoun"),
("algo", "something", "pronoun"), ("nada", "nothing", "pronoun"),
("alguien", "someone", "pronoun"), ("nadie", "nobody", "pronoun"),
("todo", "everything, all", "pronoun"), ("cada", "each", "pronoun"),
("otro", "other, another", "pronoun"), ("otra", "other, another (fem.)", "pronoun"),
("mismo", "same, self", "pronoun"), ("misma", "same, self (fem.)", "pronoun"),
// Prepositions
("a", "to, at", "preposition"), ("de", "of, from", "preposition"),
("en", "in, on, at", "preposition"), ("con", "with", "preposition"),
("por", "for, by, through", "preposition"), ("para", "for, in order to", "preposition"),
("sin", "without", "preposition"), ("sobre", "on, about", "preposition"),
("entre", "between, among", "preposition"), ("hasta", "until, up to", "preposition"),
("desde", "from, since", "preposition"), ("hacia", "toward", "preposition"),
("durante", "during", "preposition"), ("según", "according to", "preposition"),
("tras", "after, behind", "preposition"), ("contra", "against", "preposition"),
// Conjunctions
("y", "and", "conjunction"), ("e", "and (before i/hi)", "conjunction"),
("o", "or", "conjunction"), ("u", "or (before o/ho)", "conjunction"),
("pero", "but", "conjunction"), ("sino", "but rather", "conjunction"),
("porque", "because", "conjunction"), ("que", "that, which", "conjunction"),
("si", "if", "conjunction"), ("cuando", "when", "conjunction"),
("como", "as, like, how", "conjunction"), ("donde", "where", "conjunction"),
("aunque", "although", "conjunction"), ("mientras", "while", "conjunction"),
("ni", "neither, nor", "conjunction"), ("pues", "well, since", "conjunction"),
// Common adverbs
("no", "no, not", "adverb"), ("", "yes", "adverb"),
("muy", "very", "adverb"), ("más", "more, most", "adverb"),
("menos", "less, fewer", "adverb"), ("bien", "well", "adverb"),
("mal", "badly", "adverb"), ("ya", "already, now", "adverb"),
("también", "also, too", "adverb"), ("tampoco", "neither, either", "adverb"),
("aquí", "here", "adverb"), ("ahí", "there", "adverb"),
("allí", "over there", "adverb"), ("siempre", "always", "adverb"),
("nunca", "never", "adverb"), ("hoy", "today", "adverb"),
("ayer", "yesterday", "adverb"), ("mañana", "tomorrow", "adverb"),
("ahora", "now", "adverb"), ("después", "after, later", "adverb"),
("antes", "before", "adverb"), ("luego", "then, later", "adverb"),
("todavía", "still, yet", "adverb"), ("casi", "almost", "adverb"),
("solo", "only, alone", "adverb"), ("tan", "so, as", "adverb"),
("mucho", "a lot, much", "adverb"), ("poco", "little, few", "adverb"),
("bastante", "quite, enough", "adverb"), ("demasiado", "too much", "adverb"),
// Question words
("qué", "what", "interrogative"), ("quién", "who", "interrogative"),
("cómo", "how", "interrogative"), ("dónde", "where", "interrogative"),
("cuándo", "when", "interrogative"), ("cuánto", "how much", "interrogative"),
("cuál", "which", "interrogative"), ("por qué", "why", "interrogative"),
// Common nouns
("casa", "house", "noun"), ("hombre", "man", "noun"),
("mujer", "woman", "noun"), ("niño", "boy, child", "noun"),
("niña", "girl", "noun"), ("familia", "family", "noun"),
("amigo", "friend (masc.)", "noun"), ("amiga", "friend (fem.)", "noun"),
("tiempo", "time, weather", "noun"), ("día", "day", "noun"),
("noche", "night", "noun"), ("año", "year", "noun"),
("vida", "life", "noun"), ("mundo", "world", "noun"),
("país", "country", "noun"), ("ciudad", "city", "noun"),
("agua", "water", "noun"), ("comida", "food", "noun"),
("trabajo", "work, job", "noun"), ("escuela", "school", "noun"),
("libro", "book", "noun"), ("calle", "street", "noun"),
("dinero", "money", "noun"), ("mano", "hand", "noun"),
("padre", "father", "noun"), ("madre", "mother", "noun"),
("hijo", "son", "noun"), ("hija", "daughter", "noun"),
("hermano", "brother", "noun"), ("hermana", "sister", "noun"),
("persona", "person", "noun"), ("gente", "people", "noun"),
("cosa", "thing", "noun"), ("lugar", "place", "noun"),
("parte", "part", "noun"), ("nombre", "name", "noun"),
("momento", "moment", "noun"), ("problema", "problem", "noun"),
("mesa", "table", "noun"), ("puerta", "door", "noun"),
("coche", "car", "noun"), ("perro", "dog", "noun"),
("gato", "cat", "noun"), ("sol", "sun", "noun"),
("mar", "sea", "noun"), ("playa", "beach", "noun"),
("montaña", "mountain", "noun"), ("tienda", "store", "noun"),
("restaurante", "restaurant", "noun"), ("hotel", "hotel", "noun"),
("cuerpo", "body", "noun"), ("cabeza", "head", "noun"),
("corazón", "heart", "noun"), ("ojo", "eye", "noun"),
// Common adjectives
("bueno", "good", "adjective"), ("buena", "good (fem.)", "adjective"),
("malo", "bad", "adjective"), ("mala", "bad (fem.)", "adjective"),
("grande", "big, great", "adjective"), ("pequeño", "small", "adjective"),
("nuevo", "new", "adjective"), ("viejo", "old", "adjective"),
("joven", "young", "adjective"), ("largo", "long", "adjective"),
("corto", "short", "adjective"), ("alto", "tall, high", "adjective"),
("bajo", "short, low", "adjective"), ("bonito", "pretty", "adjective"),
("hermoso", "beautiful", "adjective"), ("feo", "ugly", "adjective"),
("feliz", "happy", "adjective"), ("triste", "sad", "adjective"),
("fácil", "easy", "adjective"), ("difícil", "difficult", "adjective"),
("importante", "important", "adjective"), ("posible", "possible", "adjective"),
("mejor", "better, best", "adjective"), ("peor", "worse, worst", "adjective"),
("primero", "first", "adjective"), ("último", "last", "adjective"),
("mismo", "same", "adjective"), ("otro", "other", "adjective"),
("cada", "each, every", "adjective"), ("todo", "all, every", "adjective"),
("mucho", "much, many", "adjective"), ("poco", "little, few", "adjective"),
// Numbers
("uno", "one", "number"), ("dos", "two", "number"),
("tres", "three", "number"), ("cuatro", "four", "number"),
("cinco", "five", "number"), ("seis", "six", "number"),
("siete", "seven", "number"), ("ocho", "eight", "number"),
("nueve", "nine", "number"), ("diez", "ten", "number"),
// Misc
("del", "of the (de + el)", "contraction"), ("al", "to the (a + el)", "contraction"),
]
for (word, english, pos) in words {
nonVerbIndex[word.lowercased()] = Entry(
word: word,
baseForm: word,
english: english,
partOfSpeech: pos,
tenseId: nil,
person: nil
)
}
}
}

View File

@@ -0,0 +1,121 @@
import Foundation
struct LyricsSearchResult: Sendable {
let title: String
let artist: String
let lyricsES: String
let albumArtURL: String?
let appleMusicURL: String?
}
actor LyricsSearchService {
// MARK: - Public
func searchLyrics(artist: String, title: String) async throws -> [LyricsSearchResult] {
async let lrcResults = searchLRCLIB(artist: artist, title: title)
async let itunesResults = searchITunes(artist: artist, title: title)
let lyrics = try await lrcResults
let metadata = try? await itunesResults
return lyrics.map { lrc in
let match = metadata?.bestMatch(artist: lrc.artistName, title: lrc.trackName)
return LyricsSearchResult(
title: lrc.trackName,
artist: lrc.artistName,
lyricsES: lrc.plainLyrics,
albumArtURL: match?.artworkURL600,
appleMusicURL: match?.trackViewURL
)
}
}
// MARK: - LRCLIB
private struct LRCLIBResult: Decodable, Sendable {
let trackName: String
let artistName: String
let plainLyrics: String
enum CodingKeys: String, CodingKey {
case trackName, artistName, plainLyrics
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
trackName = try c.decode(String.self, forKey: .trackName)
artistName = try c.decode(String.self, forKey: .artistName)
plainLyrics = (try? c.decode(String.self, forKey: .plainLyrics)) ?? ""
}
}
private func searchLRCLIB(artist: String, title: String) async throws -> [LRCLIBResult] {
var components = URLComponents(string: "https://lrclib.net/api/search")!
components.queryItems = [
URLQueryItem(name: "track_name", value: title),
URLQueryItem(name: "artist_name", value: artist),
]
guard let url = components.url else { return [] }
var request = URLRequest(url: url)
request.setValue("Conjuga/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return [] }
let results = try JSONDecoder().decode([LRCLIBResult].self, from: data)
return results.filter { !$0.plainLyrics.isEmpty }
}
// MARK: - iTunes Search
private struct ITunesResponse: Decodable {
let results: [ITunesTrack]
}
private struct ITunesTrack: Decodable {
let trackName: String?
let artistName: String?
let artworkUrl100: String?
let trackViewUrl: String?
var artworkURL600: String? {
artworkUrl100?.replacingOccurrences(of: "100x100", with: "600x600")
}
var trackViewURL: String? { trackViewUrl }
}
private struct ITunesMetadata: Sendable {
let tracks: [ITunesTrack]
func bestMatch(artist: String, title: String) -> ITunesTrack? {
let normalizedArtist = artist.lowercased()
let normalizedTitle = title.lowercased()
// Prefer exact title+artist match, then just title
return tracks.first {
($0.trackName ?? "").lowercased().contains(normalizedTitle) &&
($0.artistName ?? "").lowercased().contains(normalizedArtist)
} ?? tracks.first {
($0.trackName ?? "").lowercased().contains(normalizedTitle)
} ?? tracks.first
}
}
private func searchITunes(artist: String, title: String) async throws -> ITunesMetadata {
let query = "\(artist) \(title)"
var components = URLComponents(string: "https://itunes.apple.com/search")!
components.queryItems = [
URLQueryItem(name: "term", value: query),
URLQueryItem(name: "media", value: "music"),
URLQueryItem(name: "limit", value: "5"),
]
guard let url = components.url else { return ITunesMetadata(tracks: []) }
let (data, _) = try await URLSession.shared.data(from: url)
let response = try JSONDecoder().decode(ITunesResponse.self, from: data)
return ITunesMetadata(tracks: response.results)
}
}

View File

@@ -48,7 +48,7 @@ struct PracticeSessionService {
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
}
func nextCard(for focusMode: FocusMode) -> PracticeCardLoad? {
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
switch focusMode {
case .weakVerbs:
if let form = pickWeakForm() {
@@ -58,11 +58,15 @@ struct PracticeSessionService {
if let form = pickIrregularForm(filter: filter) {
return loadCard(from: form)
}
case .commonTenses:
if let form = pickCommonTenseForm() {
return loadCard(from: form)
}
case .none:
break
}
if let dueCard = fetchDueCard() {
if let dueCard = fetchDueCard(excluding: lastVerbId) {
return loadCard(from: dueCard)
}
@@ -146,7 +150,7 @@ struct PracticeSessionService {
)
}
private func fetchDueCard() -> ReviewCard? {
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
let now = Date()
@@ -157,11 +161,20 @@ struct PracticeSessionService {
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
let cards = (try? cloudContext.fetch(descriptor)) ?? []
return cards.first { card in
let eligible = cards.filter { card in
allowedVerbIds.contains(card.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
(settings.showVosotros || card.personIndex != 4)
}
// Prefer a card from a different verb than the last one shown.
// Fall back to the same verb only if it's the sole due card.
if let lastVerbId {
if let different = eligible.first(where: { $0.verbId != lastVerbId }) {
return different
}
}
return eligible.first
}
private func pickWeakForm() -> VerbForm? {
@@ -222,6 +235,20 @@ struct PracticeSessionService {
)
}
private func pickCommonTenseForm() -> VerbForm? {
let settings = settings()
let coreTenseIDs = TenseID.coreTenseIDs
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
coreTenseIDs.contains(form.tenseId) &&
(settings.showVosotros || form.personIndex != 4)
}
return forms.randomElement()
}
private func pickRandomForm() -> VerbForm? {
let settings = settings()
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)

View File

@@ -0,0 +1,155 @@
import Foundation
import Speech
import AVFoundation
@MainActor
@Observable
final class PronunciationService {
var isRecording = false
var transcript = ""
var isAuthorized = false
private var recognizer: SFSpeechRecognizer?
private var audioEngine: AVAudioEngine?
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
private var recognizerResolved = false
func requestAuthorization() {
#if targetEnvironment(simulator)
print("[PronunciationService] skipping speech auth on simulator")
return
#else
// Check current status first to avoid unnecessary prompt
let currentStatus = SFSpeechRecognizer.authorizationStatus()
if currentStatus == .authorized {
isAuthorized = true
return
}
if currentStatus == .denied || currentStatus == .restricted {
isAuthorized = false
return
}
// Only request if not determined yet do it on a background queue
// to avoid blocking main thread, then update state on main
DispatchQueue.global(qos: .userInitiated).async {
SFSpeechRecognizer.requestAuthorization { status in
DispatchQueue.main.async { [weak self] in
self?.isAuthorized = (status == .authorized)
print("[PronunciationService] authorization status: \(status.rawValue)")
}
}
}
#endif
}
private func resolveRecognizerIfNeeded() {
guard !recognizerResolved else { return }
recognizerResolved = true
recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))
}
func startRecording() {
guard isAuthorized else {
print("[PronunciationService] not authorized")
return
}
resolveRecognizerIfNeeded()
guard let recognizer, recognizer.isAvailable else {
print("[PronunciationService] recognizer unavailable")
return
}
stopRecording()
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
audioEngine = AVAudioEngine()
request = SFSpeechAudioBufferRecognitionRequest()
guard let audioEngine, let request else { return }
request.shouldReportPartialResults = true
let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
// Validate format 0 channels crashes installTap
guard recordingFormat.channelCount > 0 else {
print("[PronunciationService] invalid recording format (0 channels)")
self.audioEngine = nil
self.request = nil
return
}
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
guard buffer.frameLength > 0 else { return }
request.append(buffer)
}
audioEngine.prepare()
try audioEngine.start()
transcript = ""
isRecording = true
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
DispatchQueue.main.async {
if let result {
self?.transcript = result.bestTranscription.formattedString
}
if error != nil || (result?.isFinal == true) {
self?.stopRecording()
}
}
}
} catch {
print("[PronunciationService] startRecording failed: \(error)")
stopRecording()
}
}
func stopRecording() {
audioEngine?.stop()
audioEngine?.inputNode.removeTap(onBus: 0)
request?.endAudio()
task?.cancel()
task = nil
request = nil
audioEngine = nil
isRecording = false
}
/// Compare spoken transcript against expected text, returns matched word ratio (0.0-1.0).
static func scoreMatch(expected: String, spoken: String) -> (score: Double, matches: [WordMatch]) {
let expectedWords = expected.lowercased()
.components(separatedBy: .whitespacesAndNewlines)
.map { $0.trimmingCharacters(in: .punctuationCharacters) }
.filter { !$0.isEmpty }
let spokenWords = spoken.lowercased()
.components(separatedBy: .whitespacesAndNewlines)
.map { $0.trimmingCharacters(in: .punctuationCharacters) }
.filter { !$0.isEmpty }
let spokenSet = Set(spokenWords)
var matches: [WordMatch] = []
for word in expectedWords {
matches.append(WordMatch(word: word, matched: spokenSet.contains(word)))
}
let matchCount = matches.filter(\.matched).count
let score = expectedWords.isEmpty ? 0 : Double(matchCount) / Double(expectedWords.count)
return (score, matches)
}
struct WordMatch: Identifiable {
let word: String
let matched: Bool
var id: String { word }
}
}

View File

@@ -4,14 +4,20 @@ import AVFoundation
@MainActor
final class SpeechService {
private let synthesizer = AVSpeechSynthesizer()
private let spanishVoice: AVSpeechSynthesisVoice?
private var spanishVoice: AVSpeechSynthesisVoice?
private var voiceResolved = false
private var audioSessionConfigured = false
init() {
spanishVoice = AVSpeechSynthesisVoice(language: "es-ES")
// AVSpeechSynthesisVoice can trigger a malloc double-free on
// iOS 26 simulators when deserializing voice metadata. Defer
// voice resolution to first use so the crash doesn't happen
// during app launch.
spanishVoice = nil
}
func speak(_ text: String) {
resolveVoiceIfNeeded()
configureAudioSession()
synthesizer.stopSpeaking(at: .immediate)
let utterance = AVSpeechUtterance(string: text)
@@ -27,6 +33,12 @@ final class SpeechService {
synthesizer.stopSpeaking(at: .immediate)
}
private func resolveVoiceIfNeeded() {
guard !voiceResolved else { return }
voiceResolved = true
spanishVoice = AVSpeechSynthesisVoice(language: "es-ES")
}
private func configureAudioSession() {
guard !audioSessionConfigured else { return }
do {

View File

@@ -0,0 +1,97 @@
import Foundation
import FoundationModels
import SharedModels
import SwiftData
@MainActor
struct StoryGenerator {
// MARK: - Generable Types
@Generable
struct GeneratedStory {
@Guide(description: "A short creative title for the story in Spanish, 3-6 words")
var title: String
@Guide(description: "A one-paragraph story in Spanish, 5-8 sentences long, using vocabulary and grammar appropriate for the student level")
var bodyES: String
@Guide(description: "An accurate English translation of bodyES")
var bodyEN: String
@Guide(description: "Every word from the story annotated with its base form, English meaning, and part of speech. Include articles, prepositions, and all other words.")
var words: [GeneratedAnnotation]
@Guide(description: "3 reading comprehension questions about the story, each with 4 answer options in Spanish", .count(3))
var questions: [GeneratedQuestion]
}
@Generable
struct GeneratedAnnotation {
@Guide(description: "The exact word as it appears in the story")
var word: String
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
var baseForm: String
@Guide(description: "English translation of the word")
var english: String
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, or other")
var partOfSpeech: String
}
@Generable
struct GeneratedQuestion {
@Guide(description: "A comprehension question about the story in Spanish")
var question: String
@Guide(description: "4 answer options in Spanish", .count(4))
var options: [String]
@Guide(description: "Index of the correct answer (0-3)", .range(0...3))
var correctIndex: Int
}
// MARK: - Generation
static func generate(level: String, tenses: [String]) async throws -> Story {
let tenseNames = tenses.isEmpty
? "present, preterite, imperfect, and future"
: tenses.joined(separator: ", ")
let session = LanguageModelSession(instructions: """
You are a Spanish language teacher creating a short reading exercise.
The student's level is: \(level).
Focus on these verb tenses: \(tenseNames).
Write naturally but keep vocabulary appropriate for the level.
Use common, everyday scenarios (shopping, travel, family, school, work, food).
The story should be exactly one paragraph of 5-8 sentences.
""")
let response = try await session.respond(
to: "Create a short Spanish story for reading practice.",
generating: GeneratedStory.self
)
let story = response.content
let annotations = story.words.map {
WordAnnotation(word: $0.word, baseForm: $0.baseForm, english: $0.english, partOfSpeech: $0.partOfSpeech)
}
let questions = story.questions.map {
QuizQuestion(question: $0.question, options: $0.options, correctIndex: $0.correctIndex)
}
let annotationsJSON = (try? String(data: JSONEncoder().encode(annotations), encoding: .utf8)) ?? "[]"
let questionsJSON = (try? String(data: JSONEncoder().encode(questions), encoding: .utf8)) ?? "[]"
return Story(
title: story.title,
bodyES: story.bodyES,
bodyEN: story.bodyEN,
level: level,
wordAnnotations: annotationsJSON,
quizQuestions: questionsJSON
)
}
static var isAvailable: Bool {
SystemLanguageModel.default.availability == .available
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
import SwiftData
import Observation
@MainActor
@Observable
final class StudyTimerService {
private(set) var sessionStart: Date?
private(set) var tick: Int = 0
private var timer: Timer?
var isRunning: Bool { sessionStart != nil }
/// Seconds elapsed in the current live session.
var currentSessionSeconds: Int {
// Access `tick` so SwiftUI re-evaluates each second.
_ = tick
guard let start = sessionStart else { return 0 }
return max(0, Int(Date().timeIntervalSince(start)))
}
func start() {
guard sessionStart == nil else { return }
sessionStart = Date()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.tick += 1
}
}
}
func stop(context: ModelContext) {
guard let start = sessionStart else { return }
let elapsed = max(0, Int(Date().timeIntervalSince(start)))
sessionStart = nil
timer?.invalidate()
timer = nil
guard elapsed > 0 else { return }
let todayString = DailyLog.todayString()
let log = ReviewStore.fetchOrCreateDailyLog(dateString: todayString, context: context)
log.studySeconds += elapsed
try? context.save()
}
}

View File

@@ -45,6 +45,7 @@ enum FocusMode: Sendable {
case none
case weakVerbs
case irregularity(IrregularityFilter)
case commonTenses
}
@MainActor
@@ -96,7 +97,7 @@ final class PracticeViewModel {
hasCards = true
isLoading = true
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
guard let cardLoad = service.nextCard(for: focusMode) else {
guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else {
clearCurrentCard()
hasCards = false
isLoading = false

View File

@@ -0,0 +1,224 @@
import SwiftUI
import SharedModels
import SwiftData
struct CheckpointExamView: View {
let courseName: String
let throughWeek: Int
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query private var allDecks: [CourseDeck]
@State private var cardsByWeek: [Int: [VocabCard]] = [:]
@State private var checkpointResults: [TestResult] = []
@State private var selectedCount = 25
private let questionCounts = [25, 50, 100]
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var totalAvailable: Int {
cardsByWeek.values.reduce(0) { $0 + $1.count }
}
/// Sample evenly across weeks, then fill remainder round-robin.
private var sampledCards: [VocabCard] {
let weekNumbers = cardsByWeek.keys.sorted()
guard !weekNumbers.isEmpty else { return [] }
let target = min(selectedCount, totalAvailable)
let perWeek = target / weekNumbers.count
var remainder = target - (perWeek * weekNumbers.count)
var result: [VocabCard] = []
for week in weekNumbers {
guard let pool = cardsByWeek[week] else { continue }
let shuffled = pool.shuffled()
var take = min(perWeek, shuffled.count)
// Distribute remainder one extra card per week until exhausted
if remainder > 0 && take < shuffled.count {
take += 1
remainder -= 1
}
result.append(contentsOf: shuffled.prefix(take))
}
// If some weeks had fewer cards than perWeek, fill from weeks with surplus
if result.count < target {
let used = Set(result.map { ObjectIdentifier($0) })
var extras: [VocabCard] = []
for week in weekNumbers {
guard let pool = cardsByWeek[week] else { continue }
extras.append(contentsOf: pool.filter { !used.contains(ObjectIdentifier($0)) })
}
extras.shuffle()
result.append(contentsOf: extras.prefix(target - result.count))
}
return result.shuffled()
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
VStack(spacing: 4) {
Image(systemName: "checkmark.seal")
.font(.system(size: 44))
.foregroundStyle(.blue)
Text("Checkpoint Exam")
.font(.largeTitle.weight(.bold))
Text("Weeks 1\(throughWeek)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
// Question count picker
VStack(spacing: 8) {
Text("Questions")
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
HStack(spacing: 12) {
ForEach(questionCounts, id: \.self) { count in
let available = count <= totalAvailable
Button {
withAnimation { selectedCount = count }
} label: {
Text("\(count)")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.bordered)
.tint(selectedCount == count ? .blue : .secondary)
.disabled(!available)
.opacity(available ? 1 : 0.4)
}
}
.padding(.horizontal)
}
VStack(spacing: 8) {
Label("Multiple choice", systemImage: "list.bullet")
Label("Cumulative vocabulary", systemImage: "books.vertical")
Label("\(totalAvailable) words available", systemImage: "character.book.closed")
}
.font(.subheadline)
.foregroundStyle(.secondary)
if cardsByWeek.isEmpty {
ProgressView("Loading vocabulary...")
.padding(.top, 20)
} else {
NavigationLink {
CourseQuizView(
cards: sampledCards,
quizType: .checkpoint,
courseName: courseName,
weekNumber: throughWeek,
isFocusMode: false
)
} label: {
Text("Begin Exam")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
.padding(.horizontal)
}
// Score History
if !checkpointResults.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("Score History")
.font(.headline)
.padding(.horizontal)
VStack(spacing: 0) {
ForEach(Array(checkpointResults.prefix(10).enumerated()), id: \.offset) { _, result in
HStack {
Text(result.dateTaken.formatted(date: .abbreviated, time: .shortened))
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(result.scorePercent)%")
.font(.title3.weight(.bold))
.foregroundStyle(scoreColor(result.scorePercent))
Text("\(result.correctCount)/\(result.totalQuestions)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
Divider().padding(.leading, 14)
}
}
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
}
}
.padding(.vertical)
.adaptiveContainer()
}
.navigationTitle("Checkpoint")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
loadCumulativeCards()
loadResults()
}
}
private func loadCumulativeCards() {
let course = courseName
let maxWeek = throughWeek
let weekDecks = allDecks.filter {
$0.courseName == course && $0.weekNumber <= maxWeek && !$0.isReversed
}
var grouped: [Int: [VocabCard]] = [:]
for deck in weekDecks {
let deckId = deck.id
let descriptor = FetchDescriptor<VocabCard>(
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
)
if let fetched = try? modelContext.fetch(descriptor) {
grouped[deck.weekNumber, default: []].append(contentsOf: fetched)
}
}
cardsByWeek = grouped
}
private func loadResults() {
let course = courseName
let week = throughWeek
let checkpointType = QuizType.checkpoint.rawValue
let descriptor = FetchDescriptor<TestResult>(
predicate: #Predicate<TestResult> {
$0.courseName == course && $0.weekNumber == week && $0.quizType == checkpointType
},
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
)
checkpointResults = (try? cloudModelContext.fetch(descriptor)) ?? []
}
private func scoreColor(_ percent: Int) -> Color {
if percent >= 90 { return .green }
if percent >= 70 { return .orange }
return .red
}
}
#Preview {
NavigationStack {
CheckpointExamView(courseName: "LanGo Spanish | Beginner I", throughWeek: 3)
}
.modelContainer(for: [TestResult.self, CourseDeck.self, VocabCard.self], inMemory: true)
}

View File

@@ -18,6 +18,8 @@ struct CourseQuizView: View {
@State private var currentIndex = 0
@State private var correctCount = 0
@State private var missedItems: [MissedCourseItem] = []
@State private var isAdvancing = false
@State private var sentenceQuestion: SentenceQuizEngine.Question?
// Per-question state
@State private var userAnswer = ""
@@ -60,25 +62,29 @@ struct CourseQuizView: View {
.padding(.horizontal)
// Prompt
VStack(spacing: 8) {
Text(quizType.promptLanguage)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.textCase(.uppercase)
if quizType.isCompleteSentence, let question = sentenceQuestion {
sentencePrompt(question: question)
} else {
VStack(spacing: 8) {
Text(quizType.promptLanguage)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(quizType.prompt(for: card))
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
Text(quizType.prompt(for: card))
.font(.title.weight(.bold))
.multilineTextAlignment(.center)
if quizType.promptLanguage == "Spanish" {
Button { speechService.speak(card.front) } label: {
Image(systemName: "speaker.wave.2")
.font(.title3)
if quizType.promptLanguage == "Spanish" {
Button { speechService.speak(card.front) } label: {
Image(systemName: "speaker.wave.2")
.font(.title3)
}
.tint(.secondary)
}
.tint(.secondary)
}
.padding(.top, 8)
}
.padding(.top, 8)
// Answer area
if quizType.isMultipleChoice {
@@ -98,7 +104,7 @@ struct CourseQuizView: View {
.padding()
.adaptiveContainer()
}
.navigationTitle(isFocusMode ? "Focus Area" : "Week \(weekNumber) Test")
.navigationTitle(isFocusMode ? "Focus Area" : quizType == .checkpoint ? "Checkpoint" : "Week \(weekNumber) Test")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -111,15 +117,48 @@ struct CourseQuizView: View {
}
}
.onAppear {
shuffledCards = cards.shuffled()
let pool: [VocabCard]
if quizType.isCompleteSentence {
pool = cards.filter { SentenceQuizEngine.hasValidSentence(for: $0) }
} else {
pool = cards
}
shuffledCards = pool.shuffled()
prepareQuestion()
}
}
// MARK: - Complete the Sentence
private func sentencePrompt(question: SentenceQuizEngine.Question) -> some View {
VStack(spacing: 12) {
Text("Complete the Sentence")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(question.displayTemplate)
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal)
if !question.sentenceEN.isEmpty {
Text(question.sentenceEN)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
.padding(.top, 8)
}
// MARK: - Multiple Choice
private func multipleChoiceArea(card: VocabCard) -> some View {
VStack(spacing: 10) {
let correct = correctAnswer(for: card)
return VStack(spacing: 10) {
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
Button {
guard !isAnswered else { return }
@@ -131,7 +170,7 @@ struct CourseQuizView: View {
.font(.body.weight(.medium))
Spacer()
if isAnswered {
if option == quizType.answer(for: card) {
if option.caseInsensitiveCompare(correct) == .orderedSame {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
} else if index == selectedOption {
@@ -146,7 +185,7 @@ struct CourseQuizView: View {
}
.tint(mcTint(index: index, option: option, card: card))
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.opacity(isAnswered && option != quizType.answer(for: card) && index != selectedOption ? 0.4 : 1)
.opacity(isAnswered && option.caseInsensitiveCompare(correct) != .orderedSame && index != selectedOption ? 0.4 : 1)
.disabled(isAnswered)
}
}
@@ -155,11 +194,18 @@ struct CourseQuizView: View {
private func mcTint(index: Int, option: String, card: VocabCard) -> Color {
guard isAnswered else { return .primary }
if option == quizType.answer(for: card) { return .green }
if option.caseInsensitiveCompare(correctAnswer(for: card)) == .orderedSame { return .green }
if index == selectedOption { return .red }
return .secondary
}
private func correctAnswer(for card: VocabCard) -> String {
if quizType.isCompleteSentence, let blank = sentenceQuestion?.blankWord {
return blank
}
return quizType.answer(for: card)
}
// MARK: - Handwriting
private func handwritingArea(card: VocabCard) -> some View {
@@ -311,16 +357,13 @@ struct CourseQuizView: View {
// Nav arrows
HStack {
Button {
guard currentIndex > 0 else { return }
currentIndex -= 1
resetQuestion()
prepareQuestion()
goBack()
} label: {
Label("Previous", systemImage: "chevron.left")
.font(.subheadline)
}
.tint(.secondary)
.disabled(currentIndex == 0)
.disabled(currentIndex == 0 || isAdvancing)
Spacer()
@@ -332,6 +375,7 @@ struct CourseQuizView: View {
.labelStyle(.titleAndIcon)
}
.tint(.blue)
.disabled(isAdvancing)
}
.padding(.horizontal)
}
@@ -419,6 +463,12 @@ struct CourseQuizView: View {
selectedOption = nil
userAnswer = ""
if quizType.isCompleteSentence {
sentenceQuestion = SentenceQuizEngine.buildQuestion(for: card)
} else {
sentenceQuestion = nil
}
if quizType.isMultipleChoice {
options = generateOptions(for: card)
} else {
@@ -437,6 +487,7 @@ struct CourseQuizView: View {
hwDrawing = PKDrawing()
hwRecognizedText = ""
isRecognizing = false
sentenceQuestion = nil
}
private func submitHandwriting(card: VocabCard) {
@@ -454,11 +505,11 @@ struct CourseQuizView: View {
}
private func generateOptions(for card: VocabCard) -> [String] {
let correct = quizType.answer(for: card)
let correct = correctAnswer(for: card)
var distractors: [String] = []
var seen: Set<String> = [correct.lowercased()]
// Pull distractors from all cards in the set
// Pull distractors from all cards in the set using each card's own front
for other in shuffledCards.shuffled() {
let ans = quizType.answer(for: other)
let lower = ans.lowercased()
@@ -475,7 +526,7 @@ struct CourseQuizView: View {
}
private func checkMCAnswer(_ selected: String, card: VocabCard) {
let correct = quizType.answer(for: card)
let correct = correctAnswer(for: card)
isCorrect = selected.compare(correct, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
recordAnswer(card: card)
}
@@ -498,6 +549,8 @@ struct CourseQuizView: View {
}
private func advance() {
guard !isAdvancing else { return }
isAdvancing = true
currentIndex += 1
if isComplete {
saveResult()
@@ -505,6 +558,20 @@ struct CourseQuizView: View {
resetQuestion()
prepareQuestion()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
isAdvancing = false
}
}
private func goBack() {
guard !isAdvancing, currentIndex > 0 else { return }
isAdvancing = true
currentIndex -= 1
resetQuestion()
prepareQuestion()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
isAdvancing = false
}
}
private func saveResult() {

View File

@@ -33,11 +33,20 @@ struct CourseView: View {
private func bestScore(for week: Int) -> Int? {
let results = testResults.filter {
$0.courseName == activeCourse && $0.weekNumber == week
&& $0.quizType != QuizType.checkpoint.rawValue
}
guard !results.isEmpty else { return nil }
return results.map(\.scorePercent).max()
}
private func bestCheckpointScore(for week: Int) -> Int? {
let results = testResults.filter {
$0.courseName == activeCourse && $0.weekNumber == week
&& $0.quizType == QuizType.checkpoint.rawValue
}
return results.map(\.scorePercent).max()
}
private func shortName(_ full: String) -> String {
full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
.replacingOccurrences(of: "LanGo Spanish ", with: "")
@@ -103,6 +112,32 @@ struct CourseView: View {
DeckRowView(deck: deck)
}
}
// Checkpoint exam
NavigationLink(value: CheckpointDestination(courseName: activeCourse, throughWeek: week)) {
HStack(spacing: 12) {
Image(systemName: "checkmark.seal")
.font(.title3)
.foregroundStyle(.blue)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Checkpoint Exam")
.font(.subheadline.weight(.semibold))
Text("Cumulative review: Weeks 1\(week)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let best = bestCheckpointScore(for: week) {
Text("\(best)%")
.font(.subheadline.weight(.bold))
.foregroundStyle(best >= 90 ? .green : best >= 70 ? .orange : .red)
}
}
}
} header: {
Text("Week \(week)")
}
@@ -117,6 +152,9 @@ struct CourseView: View {
.navigationDestination(for: WeekTestDestination.self) { dest in
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
}
.navigationDestination(for: CheckpointDestination.self) { dest in
CheckpointExamView(courseName: dest.courseName, throughWeek: dest.throughWeek)
}
}
}
@@ -132,6 +170,11 @@ struct WeekTestDestination: Hashable {
let weekNumber: Int
}
struct CheckpointDestination: Hashable {
let courseName: String
let throughWeek: Int
}
// MARK: - Deck Row
private struct DeckRowView: View {

View File

@@ -56,7 +56,7 @@ struct WeekTestView: View {
.font(.headline)
.padding(.horizontal)
ForEach(QuizType.allCases) { type in
ForEach(QuizType.weeklyQuizTypes, id: \.self) { type in
NavigationLink {
CourseQuizView(
cards: weekCards,

View File

@@ -4,6 +4,7 @@ import Charts
struct DashboardView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(StudyTimerService.self) private var studyTimer
@State private var userProgress: UserProgress?
@State private var dailyLogs: [DailyLog] = []
@State private var testResults: [TestResult] = []
@@ -19,8 +20,17 @@ struct DashboardView: View {
// Summary stats
statsGrid
// Streak calendar
streakCalendar
// Study time + Activity side by side on iPad, stacked on iPhone
ViewThatFits(in: .horizontal) {
HStack(alignment: .top, spacing: 12) {
studyTimeCard
streakCalendar
}
VStack(spacing: 12) {
studyTimeCard
streakCalendar
}
}
// Accuracy chart
accuracyChart
@@ -71,6 +81,57 @@ struct DashboardView: View {
}
}
// MARK: - Study Time Card
private var studyTimeCard: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Study Time")
.font(.headline)
let todaySeconds = todayStudySeconds + studyTimer.currentSessionSeconds
HStack(spacing: 0) {
VStack(spacing: 4) {
Text(formatStudyTime(todaySeconds))
.font(.title3.bold().monospacedDigit())
Text("Today")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
VStack(spacing: 4) {
Text(formatStudyTime(totalStudySeconds))
.font(.title3.bold().monospacedDigit())
Text("Total")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
if weeklyStudyData.allSatisfy({ $0.minutes == 0 }) {
Text("Start studying to see your time")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 80)
} else {
Chart(weeklyStudyData) { day in
BarMark(
x: .value("Day", day.label),
y: .value("Minutes", day.minutes)
)
.foregroundStyle(.mint.gradient)
.cornerRadius(4)
}
.chartYAxis(.hidden)
.frame(height: 80)
}
}
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
// MARK: - Streak Calendar
private var streakCalendar: some View {
@@ -81,6 +142,7 @@ struct DashboardView: View {
StreakCalendarView(dailyLogs: dailyLogs)
}
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
@@ -167,6 +229,48 @@ struct DashboardView: View {
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
// MARK: - Study Time Computed
private var todayStudySeconds: Int {
let today = DailyLog.todayString()
return dailyLogs.first { $0.dateString == today }?.studySeconds ?? 0
}
private var totalStudySeconds: Int {
dailyLogs.reduce(0) { $0 + $1.studySeconds } + studyTimer.currentSessionSeconds
}
private var weeklyStudySeconds: Int {
weeklyStudyData.reduce(0) { $0 + $1.minutes } * 60
}
private var weeklyStudyData: [StudyDay] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
return (0..<7).reversed().map { daysAgo in
let date = calendar.date(byAdding: .day, value: -daysAgo, to: today)!
let dateStr = DailyLog.dateString(from: date)
let seconds = dailyLogs.first { $0.dateString == dateStr }?.studySeconds ?? 0
let dayLabel = daysAgo == 0 ? "Today" : Self.shortDayFormatter.string(from: date)
return StudyDay(label: dayLabel, minutes: seconds / 60)
}
}
private static let shortDayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEE"
return f
}()
private func formatStudyTime(_ totalSeconds: Int) -> String {
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
if hours > 0 {
return "\(hours)h \(minutes)m"
}
return "\(minutes)m"
}
// MARK: - Computed
private var recentLogs: [DailyLog] {
@@ -222,7 +326,14 @@ private struct StatCard: View {
}
}
private struct StudyDay: Identifiable {
let label: String
let minutes: Int
var id: String { label }
}
#Preview {
DashboardView()
.environment(StudyTimerService())
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
}

View File

@@ -0,0 +1,164 @@
import SwiftUI
struct GrammarExerciseView: View {
let noteId: String
let noteTitle: String
@Environment(\.dismiss) private var dismiss
@State private var exercises: [GrammarExercise] = []
@State private var currentIndex = 0
@State private var selectedOption: Int?
@State private var correctCount = 0
@State private var isFinished = false
var body: some View {
VStack(spacing: 20) {
if isFinished {
finishedView
} else if let ex = exercises[safe: currentIndex] {
exerciseView(ex)
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Practice: \(noteTitle)")
.navigationBarTitleDisplayMode(.inline)
.onAppear { exercises = Array(GrammarExercise.exercises(for: noteId).shuffled().prefix(10)) }
}
@ViewBuilder
private func exerciseView(_ ex: GrammarExercise) -> some View {
VStack(spacing: 20) {
Text("\(currentIndex + 1) / \(exercises.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
ProgressView(value: Double(currentIndex), total: Double(exercises.count))
.tint(.purple)
// Prompt
Text(ex.prompt)
.font(.subheadline)
.foregroundStyle(.secondary)
// Sentence
Text(highlightBlank(ex.sentence))
.font(.title3)
.multilineTextAlignment(.center)
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
// Options
VStack(spacing: 10) {
ForEach(Array(ex.options.enumerated()), id: \.offset) { index, option in
Button {
guard selectedOption == nil else { return }
selectedOption = index
if option == ex.correctAnswer { correctCount += 1 }
} label: {
HStack {
Text(option)
.font(.body.weight(.medium))
Spacer()
if let selected = selectedOption {
if option == ex.correctAnswer {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
} else if index == selected {
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(optionBG(index: index, correct: ex.correctAnswer, options: ex.options), in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
// Explanation after answer
if selectedOption != nil {
Text(ex.explanation)
.font(.callout)
.foregroundStyle(.secondary)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
.transition(.opacity)
}
Spacer()
if selectedOption != nil {
Button {
if currentIndex + 1 < exercises.count {
currentIndex += 1
selectedOption = nil
} else {
withAnimation { isFinished = true }
}
} label: {
Text(currentIndex + 1 < exercises.count ? "Next" : "See Results")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
}
}
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: correctCount == exercises.count ? "star.fill" : "checkmark.circle")
.font(.system(size: 60))
.foregroundStyle(correctCount == exercises.count ? .yellow : .purple)
Text("\(correctCount) / \(exercises.count)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text(correctCount == exercises.count ? "Perfect!" : "Keep reviewing this topic.")
.font(.title3)
.foregroundStyle(.secondary)
Button {
dismiss()
} label: {
Text("Done")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
.padding(.horizontal)
Spacer()
}
}
private func highlightBlank(_ text: String) -> AttributedString {
var result = AttributedString(text)
if let range = result.range(of: "_____") {
result[range].foregroundColor = .purple
result[range].font = .title3.bold()
}
return result
}
private func optionBG(index: Int, correct: String, options: [String]) -> some ShapeStyle {
guard let selected = selectedOption else { return AnyShapeStyle(.fill.quaternary) }
if options[index] == correct { return AnyShapeStyle(.green.opacity(0.15)) }
if index == selected { return AnyShapeStyle(.red.opacity(0.15)) }
return AnyShapeStyle(.fill.quaternary)
}
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@@ -87,6 +87,20 @@ struct GrammarNoteDetailView: View {
// Parsed body
FormattedGrammarBody(content: note.body)
// Practice button (if exercises exist for this note)
if !GrammarExercise.exercises(for: note.id).isEmpty {
NavigationLink {
GrammarExerciseView(noteId: note.id, noteTitle: note.title)
} label: {
Label("Practice This", systemImage: "pencil.and.list.clipboard")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
}
.padding()
.adaptiveContainer(maxWidth: 800)
@@ -101,40 +115,148 @@ struct GrammarNoteDetailView: View {
private struct FormattedGrammarBody: View {
let content: String
private var lines: [GrammarLine] {
GrammarLine.parse(content)
private var sections: [GrammarSection] {
GrammarSection.group(GrammarLine.parse(content))
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ForEach(lines) { line in
switch line.kind {
case .paragraph(let text):
renderParagraph(text)
case .spanishExample(let spanish):
Text(spanish)
.font(.body.weight(.medium))
.italic()
.padding(.leading, 12)
case .examplePair(let spanish, let english):
VStack(alignment: .leading, spacing: 3) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 16) {
ForEach(sections) { section in
if section.heading == nil {
// Intro content before the first heading no card
renderLines(section.lines)
} else {
// Headed section in a card
VStack(alignment: .leading, spacing: 10) {
Text(section.heading!)
.font(.headline)
renderLines(section.lines)
}
.padding(.leading, 12)
case .heading(let text):
Text(text)
.font(.headline)
.padding(.top, 6)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
}
}
@ViewBuilder
private func renderLines(_ lines: [GrammarLine]) -> some View {
let blocks = ContentBlock.group(lines)
VStack(alignment: .leading, spacing: 12) {
ForEach(blocks) { block in
switch block.kind {
case .standalone(let line):
renderSingleLine(line)
case .exampleCard(let header, let examples):
renderExampleCard(header: header, examples: examples)
case .suffixCard(let suffix, let description, let examples):
renderSuffixCard(suffix: suffix, description: description, examples: examples)
}
}
}
}
@ViewBuilder
private func renderSingleLine(_ line: GrammarLine) -> some View {
switch line.kind {
case .paragraph(let text):
renderParagraph(text)
case .spanishExample(let spanish):
Text(spanish)
.font(.body.weight(.medium))
.italic()
.padding(.leading, 12)
case .examplePair(let spanish, let english):
VStack(alignment: .leading, spacing: 3) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.leading, 12)
case .heading, .suffixDef:
EmptyView()
}
}
@ViewBuilder
private func renderExampleCard(header: GrammarLine?, examples: [GrammarLine]) -> some View {
VStack(alignment: .leading, spacing: 8) {
if let header, case .paragraph(let text) = header.kind {
renderParagraph(text)
}
VStack(alignment: .leading, spacing: 6) {
ForEach(examples) { ex in
if case .examplePair(let spanish, let english) = ex.kind {
VStack(alignment: .leading, spacing: 2) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
}
} else if case .spanishExample(let spanish) = ex.kind {
Text(spanish)
.font(.body.weight(.medium))
.italic()
}
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private func renderSuffixCard(suffix: String, description: String, examples: [GrammarLine]) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(suffix)
.font(.subheadline.weight(.bold).monospaced())
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.tint.opacity(0.12), in: Capsule())
Text(description)
.font(.callout)
.foregroundStyle(.secondary)
}
if !examples.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(examples) { ex in
if case .examplePair(let spanish, let english) = ex.kind {
HStack(spacing: 0) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(" ")
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
}
} else if case .spanishExample(let spanish) = ex.kind {
Text(spanish)
.font(.body.weight(.medium))
.italic()
}
}
}
.padding(.leading, 8)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private func renderParagraph(_ text: String) -> some View {
Text(parseInlineFormatting(text))
@@ -200,11 +322,20 @@ private struct GrammarLine: Identifiable {
let id: Int
let kind: Kind
var isExample: Bool {
switch kind {
case .examplePair, .spanishExample: return true
default: return false
}
}
enum Kind {
case paragraph(String)
case heading(String)
case spanishExample(String)
case examplePair(spanish: String, english: String)
/// A suffix definition line like `*-ito / -ita* description`
case suffixDef(suffix: String, description: String)
}
static func parse(_ body: String) -> [GrammarLine] {
@@ -255,7 +386,13 @@ private struct GrammarLine: Identifiable {
let englishPart = String(line[dashRange.upperBound...])
.replacingOccurrences(of: "*", with: "")
.trimmingCharacters(in: .whitespaces)
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
// Detect suffix definitions: spanish part starts with "-"
if spanishPart.hasPrefix("-") {
result.append(GrammarLine(id: result.count, kind: .suffixDef(suffix: spanishPart, description: englishPart)))
} else {
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
}
} else {
// Just a Spanish example without translation
let spanish = line.replacingOccurrences(of: "*", with: "")
@@ -279,6 +416,126 @@ private struct GrammarLine: Identifiable {
}
}
// MARK: - Content Block Grouping
/// Groups paragraphs with their trailing examples into visual cards.
/// Also handles suffix definitions as a special card type.
private struct ContentBlock: Identifiable {
let id: Int
enum Kind {
/// A standalone line (paragraph with no examples, or orphaned example)
case standalone(GrammarLine)
/// A paragraph header followed by example pairs rendered as a card
case exampleCard(header: GrammarLine?, examples: [GrammarLine])
/// A suffix definition with its examples rendered as a pill card
case suffixCard(suffix: String, description: String, examples: [GrammarLine])
}
let kind: Kind
static func group(_ lines: [GrammarLine]) -> [ContentBlock] {
var result: [ContentBlock] = []
var i = 0
while i < lines.count {
let line = lines[i]
// Suffix definition: collect trailing examples
if case .suffixDef(let suffix, let desc) = line.kind {
var examples: [GrammarLine] = []
var j = i + 1
while j < lines.count, lines[j].isExample {
examples.append(lines[j])
j += 1
}
result.append(ContentBlock(id: result.count, kind: .suffixCard(suffix: suffix, description: desc, examples: examples)))
i = j
continue
}
// Paragraph followed by examples: group into a card
if case .paragraph = line.kind {
var j = i + 1
// Check if examples follow
if j < lines.count, lines[j].isExample {
var examples: [GrammarLine] = []
while j < lines.count, lines[j].isExample {
examples.append(lines[j])
j += 1
}
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: line, examples: examples)))
i = j
} else {
// Standalone paragraph
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
i += 1
}
continue
}
// Orphaned examples (no preceding paragraph) group into a card
if line.isExample {
var examples: [GrammarLine] = []
var j = i
while j < lines.count, lines[j].isExample {
examples.append(lines[j])
j += 1
}
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: nil, examples: examples)))
i = j
continue
}
// Skip headings (handled at section level)
if case .heading = line.kind {
i += 1
continue
}
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
i += 1
}
return result
}
}
// MARK: - Grammar Section Grouping
private struct GrammarSection: Identifiable {
let id: Int
let heading: String?
let lines: [GrammarLine]
static func group(_ lines: [GrammarLine]) -> [GrammarSection] {
var sections: [GrammarSection] = []
var currentHeading: String? = nil
var currentLines: [GrammarLine] = []
var sectionIndex = 0
for line in lines {
if case .heading(let text) = line.kind {
// Flush the previous section
if !currentLines.isEmpty || currentHeading != nil {
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
sectionIndex += 1
currentLines = []
}
currentHeading = text
} else {
currentLines.append(line)
}
}
// Flush final section
if !currentLines.isEmpty || currentHeading != nil {
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
}
return sections
}
}
// MARK: - Hashable/Equatable conformance for NavigationLink
extension GrammarNote: Hashable, Equatable {

View File

@@ -100,12 +100,25 @@ private struct TenseRowView: View {
let tense: TenseInfo
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(tense.english)
.font(.headline)
Text(tense.spanish)
.font(.subheadline)
.foregroundStyle(.secondary)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(tense.english)
.font(.headline)
Text(tense.spanish)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if tense.isCore {
Text("Essential")
.font(.caption2.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.orange.opacity(0.12), in: Capsule())
}
}
}
}

View File

@@ -0,0 +1,126 @@
import SwiftUI
import SharedModels
import SwiftData
struct ChatLibraryView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var conversations: [Conversation] = []
@State private var showingScenarioPicker = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
Group {
if conversations.isEmpty {
ContentUnavailableView(
"No Conversations Yet",
systemImage: "bubble.left.and.bubble.right",
description: Text("Tap + to start a Spanish conversation.")
)
} else {
List {
ForEach(conversations) { conv in
NavigationLink {
ChatView(conversation: conv)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(conv.scenario)
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Text(conv.level.capitalized)
.font(.caption2.weight(.medium))
.foregroundStyle(.green)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.green.opacity(0.12), in: Capsule())
let msgCount = conv.decodedMessages.count
Text("\(msgCount) message\(msgCount == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
.onDelete(perform: deleteConversations)
}
}
}
.navigationTitle("Conversations")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingScenarioPicker = true
} label: {
Image(systemName: "plus")
}
.disabled(!ConversationService.isAvailable)
}
}
.sheet(isPresented: $showingScenarioPicker) {
ScenarioPickerView { scenario in
showingScenarioPicker = false
createConversation(scenario: scenario)
}
}
.onAppear(perform: loadConversations)
}
private func loadConversations() {
let descriptor = FetchDescriptor<Conversation>(
sortBy: [SortDescriptor(\Conversation.createdDate, order: .reverse)]
)
conversations = (try? cloudContext.fetch(descriptor)) ?? []
}
private func createConversation(scenario: String) {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let conv = Conversation(scenario: scenario, level: progress.selectedLevel)
cloudContext.insert(conv)
try? cloudContext.save()
loadConversations()
}
private func deleteConversations(at offsets: IndexSet) {
for index in offsets { cloudContext.delete(conversations[index]) }
try? cloudContext.save()
loadConversations()
}
}
// MARK: - Scenario Picker
struct ScenarioPickerView: View {
let onPick: (String) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(ConversationService.scenarios, id: \.self) { scenario in
Button {
onPick(scenario)
} label: {
HStack {
Text(scenario)
.font(.body)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.tint(.primary)
}
}
.navigationTitle("Choose a Scenario")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}

View File

@@ -0,0 +1,150 @@
import SwiftUI
import SharedModels
import SwiftData
struct ChatView: View {
let conversation: Conversation
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var service = ConversationService()
@State private var messages: [ChatMessage] = []
@State private var inputText = ""
@State private var errorMessage: String?
@State private var hasStarted = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
VStack(spacing: 0) {
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(messages) { message in
ChatBubble(message: message)
.id(message.id)
}
if service.isResponding {
HStack {
ProgressView()
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 16))
Spacer()
}
.padding(.horizontal)
}
}
.padding(.vertical)
}
.onChange(of: messages.count) {
if let last = messages.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
Divider()
// Input
HStack(spacing: 8) {
TextField("Type in Spanish...", text: $inputText)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.sentences)
.onSubmit { sendMessage() }
Button {
sendMessage()
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.disabled(inputText.trimmingCharacters(in: .whitespaces).isEmpty || service.isResponding)
.tint(.green)
}
.padding()
}
.navigationTitle(conversation.scenario)
.navigationBarTitleDisplayMode(.inline)
.alert("Error", isPresented: .init(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
Button("OK") { errorMessage = nil }
} message: {
Text(errorMessage ?? "")
}
.onAppear {
messages = conversation.decodedMessages
if !hasStarted && messages.isEmpty {
startConversation()
}
hasStarted = true
}
}
private func startConversation() {
let opening = service.startConversation(scenario: conversation.scenario, level: conversation.level)
let msg = ChatMessage(role: "assistant", content: opening)
conversation.appendMessage(msg)
messages = conversation.decodedMessages
try? cloudContext.save()
}
private func sendMessage() {
let text = inputText.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { return }
let userMsg = ChatMessage(role: "user", content: text)
conversation.appendMessage(userMsg)
messages = conversation.decodedMessages
inputText = ""
try? cloudContext.save()
Task {
do {
let response = try await service.respond(to: text)
let assistantMsg = ChatMessage(role: "assistant", content: response)
conversation.appendMessage(assistantMsg)
messages = conversation.decodedMessages
try? cloudContext.save()
} catch {
errorMessage = "Failed to get response: \(error.localizedDescription)"
}
}
}
}
// MARK: - Chat Bubble
private struct ChatBubble: View {
let message: ChatMessage
private var isUser: Bool { message.role == "user" }
var body: some View {
HStack {
if isUser { Spacer(minLength: 60) }
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
Text(message.content)
.font(.body)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(isUser ? AnyShapeStyle(.green.opacity(0.2)) : AnyShapeStyle(.fill.quaternary), in: RoundedRectangle(cornerRadius: 16))
if let correction = message.correction, !correction.isEmpty {
Text(correction)
.font(.caption)
.foregroundStyle(.orange)
.padding(.horizontal, 4)
}
}
if !isUser { Spacer(minLength: 60) }
}
.padding(.horizontal)
}
}

View File

@@ -0,0 +1,212 @@
import SwiftUI
import SharedModels
import SwiftData
struct ClozeView: View {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var questions: [ClozeQuestion] = []
@State private var currentIndex = 0
@State private var selectedOption: Int?
@State private var correctCount = 0
@State private var isFinished = false
@State private var isLoading = true
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
VStack(spacing: 20) {
if isLoading {
ProgressView("Loading questions...")
} else if isFinished || questions.isEmpty {
finishedView
} else if let q = questions[safe: currentIndex] {
questionView(q)
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Cloze Practice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadQuestions)
}
// MARK: - Question View
@ViewBuilder
private func questionView(_ q: ClozeQuestion) -> some View {
VStack(spacing: 20) {
Text("\(currentIndex + 1) / \(questions.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
ProgressView(value: Double(currentIndex), total: Double(questions.count))
.tint(.indigo)
// Sentence with blank
Text(highlightedTemplate(q.displayTemplate))
.font(.title3)
.multilineTextAlignment(.center)
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
// English hint
if !q.sentenceEN.isEmpty {
Text(q.sentenceEN)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
// Options
VStack(spacing: 10) {
ForEach(Array(q.options.enumerated()), id: \.offset) { index, option in
Button {
guard selectedOption == nil else { return }
selectedOption = index
if option.lowercased() == q.answer.lowercased() {
correctCount += 1
}
} label: {
HStack {
Text(option)
.font(.body)
Spacer()
if let selected = selectedOption {
if option.lowercased() == q.answer.lowercased() {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
} else if index == selected {
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(optionBG(index: index, answer: q.answer, options: q.options), in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
Spacer()
if selectedOption != nil {
Button {
if currentIndex + 1 < questions.count {
currentIndex += 1
selectedOption = nil
} else {
withAnimation { isFinished = true }
}
} label: {
Text(currentIndex + 1 < questions.count ? "Next" : "See Results")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.indigo)
}
}
}
// MARK: - Finished
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: questions.isEmpty ? "text.badge.xmark" : "checkmark.circle")
.font(.system(size: 60))
.foregroundStyle(questions.isEmpty ? Color.secondary : Color.indigo)
if questions.isEmpty {
Text("No cloze questions available")
.font(.title3)
.foregroundStyle(.secondary)
Text("Complete some course decks first to unlock cloze practice.")
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
} else {
Text("\(correctCount) / \(questions.count)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text(correctCount == questions.count ? "Perfect!" : "Keep practicing!")
.font(.title3)
.foregroundStyle(.secondary)
}
Spacer()
}
}
// MARK: - Helpers
private func highlightedTemplate(_ template: String) -> AttributedString {
var result = AttributedString(template)
if let range = result.range(of: SentenceQuizEngine.blankMarker) {
result[range].foregroundColor = .indigo
result[range].font = .title3.bold()
}
return result
}
private func optionBG(index: Int, answer: String, options: [String]) -> some ShapeStyle {
guard let selected = selectedOption else { return AnyShapeStyle(.fill.quaternary) }
if options[index].lowercased() == answer.lowercased() { return AnyShapeStyle(.green.opacity(0.15)) }
if index == selected { return AnyShapeStyle(.red.opacity(0.15)) }
return AnyShapeStyle(.fill.quaternary)
}
private func loadQuestions() {
let descriptor = FetchDescriptor<VocabCard>()
let allCards = (try? localContext.fetch(descriptor)) ?? []
let eligible = allCards.filter { SentenceQuizEngine.hasValidSentence(for: $0) }
var result: [ClozeQuestion] = []
let pool = eligible.shuffled().prefix(20)
for card in pool {
guard let q = SentenceQuizEngine.buildQuestion(for: card) else { continue }
// Build distractors from other cards
var distractors = eligible
.filter { $0.front != card.front }
.shuffled()
.prefix(3)
.map(\.front)
while distractors.count < 3 {
distractors.append("---")
}
var options = Array(distractors) + [q.blankWord]
options.shuffle()
result.append(ClozeQuestion(
displayTemplate: q.displayTemplate,
sentenceEN: q.sentenceEN,
answer: q.blankWord,
options: options
))
if result.count >= 10 { break }
}
questions = result
isLoading = false
}
}
private struct ClozeQuestion {
let displayTemplate: String
let sentenceEN: String
let answer: String
let options: [String]
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@@ -0,0 +1,319 @@
import SwiftUI
import SharedModels
import SwiftData
struct ListeningView: View {
@Environment(\.modelContext) private var localContext
@State private var pronunciation = PronunciationService()
@State private var speechService = SpeechService()
@State private var sentences: [(spanish: String, english: String)] = []
@State private var currentIndex = 0
@State private var userInput = ""
@State private var isRevealed = false
@State private var score: Double?
@State private var wordMatches: [PronunciationService.WordMatch] = []
@State private var mode: ListeningMode = .listenType
@State private var correctCount = 0
@State private var isFinished = false
enum ListeningMode: String, CaseIterable {
case listenType = "Listen & Type"
case speakCheck = "Pronunciation"
}
var body: some View {
VStack(spacing: 20) {
if isFinished {
finishedView
} else if sentences.isEmpty {
ContentUnavailableView("No sentences available", systemImage: "waveform", description: Text("Complete some course decks first."))
} else {
exerciseView
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Listening Practice")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
print("[ListeningView] onAppear — loading sentences")
loadSentences()
print("[ListeningView] loaded \(sentences.count) sentences, requesting auth")
Task {
pronunciation.requestAuthorization()
}
}
}
// MARK: - Exercise
@ViewBuilder
private var exerciseView: some View {
VStack(spacing: 20) {
// Mode picker
Picker("Mode", selection: $mode) {
ForEach(ListeningMode.allCases, id: \.self) { m in
Text(m.rawValue).tag(m)
}
}
.pickerStyle(.segmented)
Text("\(currentIndex + 1) / \(sentences.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
if mode == .listenType {
listenAndTypeView
} else {
pronunciationCheckView
}
}
}
// MARK: - Listen & Type
@ViewBuilder
private var listenAndTypeView: some View {
let sentence = sentences[currentIndex]
VStack(spacing: 16) {
// Play button
Button {
speechService.speak(sentence.spanish)
} label: {
Label("Play", systemImage: "speaker.wave.2.fill")
.font(.headline)
.padding()
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.blue)
// User types what they heard
TextField("Type what you hear...", text: $userInput)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if isRevealed {
// Show correct answer
VStack(alignment: .leading, spacing: 8) {
Text("Correct:")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(sentence.spanish)
.font(.body.weight(.medium))
Text(sentence.english)
.font(.callout)
.foregroundStyle(.secondary)
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
Text("Score: \(Int(result.score * 100))%")
.font(.headline)
.foregroundStyle(result.score >= 0.8 ? .green : result.score >= 0.5 ? .orange : .red)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
nextButton
} else {
Button {
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
if result.score >= 0.7 { correctCount += 1 }
withAnimation { isRevealed = true }
} label: {
Text("Check")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
.disabled(userInput.isEmpty)
}
}
}
// MARK: - Pronunciation Check
@ViewBuilder
private var pronunciationCheckView: some View {
let sentence = sentences[currentIndex]
VStack(spacing: 16) {
// Show the sentence to read
Text(sentence.spanish)
.font(.title3.weight(.medium))
.multilineTextAlignment(.center)
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
Text(sentence.english)
.font(.callout)
.foregroundStyle(.secondary)
// Mic button
Button {
if pronunciation.isRecording {
pronunciation.stopRecording()
// Score after stopping
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: pronunciation.transcript)
score = result.score
wordMatches = result.matches
if result.score >= 0.7 { correctCount += 1 }
withAnimation { isRevealed = true }
} else {
pronunciation.startRecording()
}
} label: {
Label(pronunciation.isRecording ? "Stop" : "Start Speaking", systemImage: pronunciation.isRecording ? "stop.circle.fill" : "mic.circle.fill")
.font(.headline)
.padding()
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(pronunciation.isRecording ? .red : .green)
.disabled(!pronunciation.isAuthorized)
if !pronunciation.isAuthorized {
Text("Microphone access required. Enable in Settings.")
.font(.caption)
.foregroundStyle(.secondary)
}
if pronunciation.isRecording {
Text(pronunciation.transcript.isEmpty ? "Listening..." : pronunciation.transcript)
.font(.body)
.foregroundStyle(.secondary)
.italic()
}
if isRevealed, let score {
VStack(spacing: 8) {
Text("\(Int(score * 100))% match")
.font(.title2.bold())
.foregroundStyle(score >= 0.8 ? .green : score >= 0.5 ? .orange : .red)
// Word-by-word feedback
FlowLayout(spacing: 4) {
ForEach(wordMatches) { match in
Text(match.word)
.font(.body)
.foregroundStyle(match.matched ? .green : .red)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(match.matched ? .green.opacity(0.1) : .red.opacity(0.1), in: Capsule())
}
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
nextButton
}
}
}
// MARK: - Shared
private var nextButton: some View {
Button {
if currentIndex + 1 < sentences.count {
currentIndex += 1
userInput = ""
isRevealed = false
score = nil
wordMatches = []
} else {
withAnimation { isFinished = true }
}
} label: {
Text(currentIndex + 1 < sentences.count ? "Next" : "See Results")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "ear.fill")
.font(.system(size: 60))
.foregroundStyle(.blue)
Text("\(correctCount) / \(sentences.count)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text("Listening complete!")
.font(.title3)
.foregroundStyle(.secondary)
Spacer()
}
}
private func loadSentences() {
print("[ListeningView] fetching VocabCards from localContext...")
print("[ListeningView] context: \(localContext)")
let descriptor = FetchDescriptor<VocabCard>()
let cards: [VocabCard]
do {
let count = try localContext.fetchCount(descriptor)
print("[ListeningView] fetchCount = \(count)")
cards = try localContext.fetch(descriptor)
print("[ListeningView] fetched \(cards.count) VocabCards")
} catch {
print("[ListeningView] ERROR fetching VocabCards: \(error)")
return
}
var results: [(String, String)] = []
for card in cards.shuffled() {
for i in card.examplesES.indices {
let es = card.examplesES[i]
let en = i < card.examplesEN.count ? card.examplesEN[i] : ""
if es.split(separator: " ").count >= 4 {
results.append((es, en))
}
if results.count >= 10 { break }
}
if results.count >= 10 { break }
}
sentences = results
print("[ListeningView] selected \(sentences.count) sentences")
}
}
// Reuse FlowLayout from StoryReaderView import not needed since it's in the same module
// but we need a local copy since it's private there
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 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
}
}

View File

@@ -0,0 +1,216 @@
import SwiftUI
import SharedModels
import SwiftData
import Translation
struct LyricsConfirmationView: View {
let result: LyricsSearchResult
let onSave: () -> Void
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var translatedEN = ""
@State private var isTranslating = true
@State private var translationError = false
@State private var translationConfig: TranslationSession.Configuration?
var body: some View {
ScrollView {
VStack(spacing: 20) {
headerSection
lyricsPreview
actionButtons
}
.padding()
.adaptiveContainer()
}
.navigationTitle("Confirm Lyrics")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
translationConfig = .init(
source: Locale.Language(identifier: "es"),
target: Locale.Language(identifier: "en")
)
}
.translationTask(translationConfig) { session in
await translateLyrics(session: session)
}
}
// MARK: - Sections
private var headerSection: some View {
VStack(spacing: 12) {
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 12)
.fill(.fill.quaternary)
.overlay {
ProgressView()
}
}
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Text(result.title)
.font(.title2.weight(.bold))
.multilineTextAlignment(.center)
Text(result.artist)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
private var lyricsPreview: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Spanish Lyrics")
.font(.headline)
Text(result.lyricsES.prefix(500) + (result.lyricsES.count > 500 ? "\n..." : ""))
.font(.body)
.foregroundStyle(.primary)
Divider()
HStack {
Text("English Translation")
.font(.headline)
if isTranslating {
ProgressView()
.controlSize(.small)
}
}
if translationError {
Label("Translation unavailable. You can still save with Spanish only.",
systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
} else if translatedEN.isEmpty && isTranslating {
Text("Translating...")
.font(.body)
.foregroundStyle(.secondary)
} else {
Text(translatedEN.prefix(500) + (translatedEN.count > 500 ? "\n..." : ""))
.font(.body)
.foregroundStyle(.secondary)
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
private var actionButtons: some View {
HStack(spacing: 16) {
Button(role: .cancel) {
dismiss()
} label: {
Text("Cancel")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
Button {
saveSong()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.disabled(isTranslating && translatedEN.isEmpty && !translationError)
}
}
// MARK: - Logic
private func translateLyrics(session: sending TranslationSession) async {
await MainActor.run { isTranslating = true }
let text = result.lyricsES
let esLines = text.components(separatedBy: "\n")
print("[LyricsTranslation] Spanish line count: \(esLines.count)")
print("[LyricsTranslation] Spanish blank lines: \(esLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
print("[LyricsTranslation] First 10 ES lines:")
for (i, line) in esLines.prefix(10).enumerated() {
print(" ES[\(i)]: \(line.isEmpty ? "(blank)" : line)")
}
do {
let response = try await session.translate(text)
let enLines = response.targetText.components(separatedBy: "\n")
print("[LyricsTranslation] English line count: \(enLines.count)")
print("[LyricsTranslation] English blank lines: \(enLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
print("[LyricsTranslation] First 10 EN lines:")
for (i, line) in enLines.prefix(10).enumerated() {
print(" EN[\(i)]: \(line.isEmpty ? "(blank)" : line)")
}
// The Translation framework often inserts a blank line after every
// translated line. Collapse consecutive blank lines into single blanks,
// then trim leading/trailing blanks so the EN structure matches the ES.
let normalized = Self.normalizeTranslationLineBreaks(
translated: response.targetText,
originalES: text
)
let normalizedLines = normalized.components(separatedBy: "\n")
print("[LyricsTranslation] After normalization: EN lines \(enLines.count)\(normalizedLines.count) (target: \(esLines.count))")
await MainActor.run { translatedEN = normalized }
} catch {
print("[LyricsTranslation] Translation error: \(error)")
await MainActor.run { translationError = true }
}
await MainActor.run { isTranslating = false }
}
/// Re-align translated line breaks to match the original Spanish structure.
/// The Translation framework often inserts a blank line after every translated
/// line. We strip all blanks from the EN output, then re-insert them at the
/// same positions where the original ES text has blank lines.
/// Re-align translated line breaks to match the original Spanish structure.
private static func normalizeTranslationLineBreaks(translated: String, originalES: String) -> String {
let esLines = originalES.components(separatedBy: "\n")
let enContentLines = translated.components(separatedBy: "\n")
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
// Walk ES lines. For each blank ES line, insert a blank in the result.
// For each content ES line, consume the next EN content line.
var result: [String] = []
var enIndex = 0
for esLine in esLines {
if esLine.trimmingCharacters(in: .whitespaces).isEmpty {
result.append("")
} else if enIndex < enContentLines.count {
result.append(enContentLines[enIndex])
enIndex += 1
} else {
result.append("")
}
}
print("[LyricsNormalize] ES lines: \(esLines.count), EN content: \(enContentLines.count), result: \(result.count), EN consumed: \(enIndex)")
return result.joined(separator: "\n")
}
private func saveSong() {
let song = SavedSong(
title: result.title,
artist: result.artist,
lyricsES: result.lyricsES,
lyricsEN: translatedEN,
albumArtURL: result.albumArtURL ?? "",
appleMusicURL: result.appleMusicURL ?? ""
)
cloudModelContext.insert(song)
try? cloudModelContext.save()
onSave()
}
}

View File

@@ -0,0 +1,107 @@
import SwiftUI
import SharedModels
import SwiftData
struct LyricsLibraryView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var songs: [SavedSong] = []
@State private var showingSearch = false
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View {
Group {
if songs.isEmpty {
ContentUnavailableView(
"No Songs Yet",
systemImage: "music.note.list",
description: Text("Tap + to search for Spanish song lyrics.")
)
} else {
List {
ForEach(songs) { song in
NavigationLink {
LyricsReaderView(song: song)
} label: {
SongRowView(song: song)
}
}
.onDelete(perform: deleteSongs)
}
}
}
.navigationTitle("Lyrics")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingSearch = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingSearch) {
NavigationStack {
LyricsSearchView {
showingSearch = false
loadSongs()
}
}
}
.onAppear(perform: loadSongs)
}
private func loadSongs() {
let descriptor = FetchDescriptor<SavedSong>(
sortBy: [SortDescriptor(\SavedSong.savedDate, order: .reverse)]
)
songs = (try? cloudModelContext.fetch(descriptor)) ?? []
}
private func deleteSongs(at offsets: IndexSet) {
for index in offsets {
cloudModelContext.delete(songs[index])
}
try? cloudModelContext.save()
loadSongs()
}
}
// MARK: - Song Row
private struct SongRowView: View {
let song: SavedSong
var body: some View {
HStack(spacing: 12) {
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(.fill.quaternary)
}
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Image(systemName: "music.note")
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 50, height: 50)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
}
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(song.artist)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.vertical, 2)
}
}

View File

@@ -0,0 +1,97 @@
import SwiftUI
import SharedModels
struct LyricsReaderView: View {
let song: SavedSong
var body: some View {
ScrollView {
VStack(spacing: 20) {
headerSection
lyricsBody
}
.padding()
.adaptiveContainer()
}
.navigationTitle(song.title)
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 10) {
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 12)
.fill(.fill.quaternary)
}
.frame(width: 160, height: 160)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Text(song.title)
.font(.title2.weight(.bold))
.multilineTextAlignment(.center)
Text(song.artist)
.font(.subheadline)
.foregroundStyle(.secondary)
if !song.appleMusicURL.isEmpty, let url = URL(string: song.appleMusicURL) {
Link(destination: url) {
Label("Open in Apple Music", systemImage: "apple.logo")
.font(.caption.weight(.medium))
}
.tint(.pink)
}
}
}
// MARK: - Lyrics Body
private var lyricsBody: some View {
let spanishLines = song.lyricsES.components(separatedBy: "\n")
let englishLines = song.lyricsEN.components(separatedBy: "\n")
let lineCount = max(spanishLines.count, englishLines.count)
let _ = {
print("[LyricsReader] ES lines: \(spanishLines.count), EN lines: \(englishLines.count), rendering: \(lineCount)")
for i in 0..<min(15, lineCount) {
let es = i < spanishLines.count ? spanishLines[i] : "(none)"
let en = i < englishLines.count ? englishLines[i] : "(none)"
print(" [\(i)] ES: \(es.isEmpty ? "(blank)" : es)")
print(" EN: \(en.isEmpty ? "(blank)" : en)")
}
}()
return VStack(alignment: .leading, spacing: 0) {
ForEach(0..<lineCount, id: \.self) { index in
let es = index < spanishLines.count ? spanishLines[index] : ""
let en = index < englishLines.count ? englishLines[index] : ""
if es.trimmingCharacters(in: .whitespaces).isEmpty &&
en.trimmingCharacters(in: .whitespaces).isEmpty {
// Blank line = section divider
Spacer().frame(height: 20)
} else {
VStack(alignment: .leading, spacing: 2) {
if !es.isEmpty {
Text(es)
.font(.body.weight(.medium))
}
if !en.isEmpty {
Text(en)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}

View File

@@ -0,0 +1,179 @@
import SwiftUI
import SharedModels
import SwiftData
import Translation
struct LyricsSearchView: View {
var onSaved: (() -> Void)?
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var artist = ""
@State private var songTitle = ""
@State private var isSearching = false
@State private var searchResults: [LyricsSearchResult] = []
@State private var errorMessage: String?
@State private var selectedResult: LyricsSearchResult?
private let service = LyricsSearchService()
var body: some View {
List {
searchSection
if isSearching {
loadingSection
}
if let errorMessage {
errorSection(errorMessage)
}
if !searchResults.isEmpty {
resultsSection
}
}
.navigationTitle("Search Lyrics")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(item: $selectedResult) { result in
LyricsConfirmationView(result: result) {
if let onSaved {
onSaved()
} else {
dismiss()
}
}
}
}
// MARK: - Sections
private var searchSection: some View {
Section {
TextField("Artist", text: $artist)
.textInputAutocapitalization(.words)
.autocorrectionDisabled()
TextField("Song Title", text: $songTitle)
.textInputAutocapitalization(.words)
.autocorrectionDisabled()
Button {
performSearch()
} label: {
HStack {
Spacer()
Label("Search", systemImage: "magnifyingglass")
.font(.headline)
Spacer()
}
}
.disabled(artist.trimmingCharacters(in: .whitespaces).isEmpty ||
songTitle.trimmingCharacters(in: .whitespaces).isEmpty ||
isSearching)
} header: {
Text("Find a Song")
}
}
private var loadingSection: some View {
Section {
HStack {
Spacer()
ProgressView("Searching...")
Spacer()
}
.padding(.vertical, 8)
}
}
private func errorSection(_ message: String) -> some View {
Section {
Label(message, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
private var resultsSection: some View {
Section {
ForEach(Array(searchResults.prefix(5).enumerated()), id: \.offset) { _, result in
Button {
selectedResult = result
} label: {
HStack(spacing: 12) {
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(.fill.quaternary)
}
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Image(systemName: "music.note")
.font(.title2)
.frame(width: 50, height: 50)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
}
VStack(alignment: .leading, spacing: 2) {
Text(result.title)
.font(.subheadline.weight(.semibold))
Text(result.artist)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.tint(.primary)
}
} header: {
Text("Results")
}
}
// MARK: - Actions
private func performSearch() {
isSearching = true
errorMessage = nil
searchResults = []
Task {
do {
let results = try await service.searchLyrics(artist: artist, title: songTitle)
searchResults = results
if results.isEmpty {
errorMessage = "No lyrics found. Try a different spelling."
}
} catch {
errorMessage = "Search failed. Check your connection."
}
isSearching = false
}
}
}
// MARK: - Identifiable conformance for navigation
extension LyricsSearchResult: Equatable {
static func == (lhs: LyricsSearchResult, rhs: LyricsSearchResult) -> Bool {
lhs.title == rhs.title && lhs.artist == rhs.artist && lhs.lyricsES == rhs.lyricsES
}
}
extension LyricsSearchResult: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(title)
hasher.combine(artist)
}
}
extension LyricsSearchResult: Identifiable {
var id: String { "\(artist)\(title)" }
}

View File

@@ -98,12 +98,245 @@ struct PracticeView: View {
}
.padding(.horizontal)
// Lyrics
NavigationLink {
LyricsLibraryView()
} label: {
HStack(spacing: 14) {
Image(systemName: "music.note.list")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.pink)
VStack(alignment: .leading, spacing: 2) {
Text("Lyrics")
.font(.subheadline.weight(.semibold))
Text("Read Spanish song lyrics with translations")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Conversation Practice
NavigationLink {
ChatLibraryView()
} label: {
HStack(spacing: 14) {
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Conversation")
.font(.subheadline.weight(.semibold))
Text("Chat with AI in Spanish scenarios")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Listening Practice
NavigationLink {
ListeningView()
} label: {
HStack(spacing: 14) {
Image(systemName: "ear.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("Listening")
.font(.subheadline.weight(.semibold))
Text("Listen and type, or practice pronunciation")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Cloze Practice
NavigationLink {
ClozeView()
} label: {
HStack(spacing: 14) {
Image(systemName: "text.badge.minus")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.indigo)
VStack(alignment: .leading, spacing: 2) {
Text("Cloze Practice")
.font(.subheadline.weight(.semibold))
Text("Fill in the missing word in context")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Stories
NavigationLink {
StoryLibraryView()
} label: {
HStack(spacing: 14) {
Image(systemName: "book.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.teal)
VStack(alignment: .leading, spacing: 2) {
Text("Stories")
.font(.subheadline.weight(.semibold))
Text("Read AI-generated Spanish stories")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Quick Actions
VStack(spacing: 12) {
Text("Quick Actions")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
// Vocab review
NavigationLink {
VocabReviewView()
} label: {
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.teal)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Vocab Review")
.font(.subheadline.weight(.semibold))
Text("Review due vocabulary cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
if dueCount > 0 {
Text("\(dueCount)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.teal, in: Capsule())
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Common tenses focus
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .commonTenses
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { isPracticing = true }
} label: {
HStack(spacing: 14) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.orange)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Common Tenses")
.font(.subheadline.weight(.semibold))
Text("Practice the 6 most essential tenses")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Weak verbs focus
Button {
viewModel.practiceMode = .flashcard

View File

@@ -0,0 +1,134 @@
import SwiftUI
import SharedModels
import SwiftData
struct StoryLibraryView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var stories: [Story] = []
@State private var isGenerating = false
@State private var errorMessage: String?
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View {
Group {
if stories.isEmpty && !isGenerating {
ContentUnavailableView(
"No Stories Yet",
systemImage: "book.closed",
description: Text("Tap + to generate a Spanish story with AI.")
)
} else {
List {
if isGenerating {
HStack(spacing: 12) {
ProgressView()
Text("Generating story...")
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
ForEach(stories) { story in
NavigationLink {
StoryReaderView(story: story)
} label: {
StoryRowView(story: story)
}
}
.onDelete(perform: deleteStories)
}
}
}
.navigationTitle("Stories")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
generateStory()
} label: {
Image(systemName: "plus")
}
.disabled(isGenerating || !StoryGenerator.isAvailable)
}
}
.alert("Error", isPresented: .init(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
Button("OK") { errorMessage = nil }
} message: {
Text(errorMessage ?? "")
}
.onAppear(perform: loadStories)
}
private func loadStories() {
let descriptor = FetchDescriptor<Story>(
sortBy: [SortDescriptor(\Story.createdDate, order: .reverse)]
)
stories = (try? cloudModelContext.fetch(descriptor)) ?? []
}
private func generateStory() {
guard !isGenerating else { return }
guard StoryGenerator.isAvailable else {
errorMessage = "Apple Intelligence is not available on this device. Stories require an iPhone 15 Pro or later with Apple Intelligence enabled."
return
}
isGenerating = true
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
let level = progress.selectedLevel
let tenses = progress.enabledTenseIDs
Task {
do {
let story = try await StoryGenerator.generate(level: level, tenses: tenses)
cloudModelContext.insert(story)
try? cloudModelContext.save()
loadStories()
} catch {
errorMessage = "Failed to generate story: \(error.localizedDescription)"
}
isGenerating = false
}
}
private func deleteStories(at offsets: IndexSet) {
for index in offsets {
cloudModelContext.delete(stories[index])
}
try? cloudModelContext.save()
loadStories()
}
}
// MARK: - Story Row
private struct StoryRowView: View {
let story: Story
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(story.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
HStack(spacing: 8) {
Text(story.level.capitalized)
.font(.caption2.weight(.medium))
.foregroundStyle(.teal)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.teal.opacity(0.12), in: Capsule())
Text(story.createdDate.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}

View File

@@ -0,0 +1,152 @@
import SwiftUI
import SharedModels
struct StoryQuizView: View {
let story: Story
@State private var currentIndex = 0
@State private var selectedOption: Int?
@State private var correctCount = 0
@State private var isFinished = false
private var questions: [QuizQuestion] { story.decodedQuestions }
var body: some View {
VStack(spacing: 24) {
if isFinished {
finishedView
} else if let question = questions[safe: currentIndex] {
questionView(question)
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Comprehension Quiz")
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Question View
@ViewBuilder
private func questionView(_ question: QuizQuestion) -> some View {
VStack(spacing: 20) {
// Progress
Text("Question \(currentIndex + 1) of \(questions.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
// Question
Text(question.question)
.font(.title3.weight(.semibold))
.multilineTextAlignment(.center)
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
// Options
VStack(spacing: 10) {
ForEach(Array(question.options.enumerated()), id: \.offset) { index, option in
Button {
guard selectedOption == nil else { return }
selectedOption = index
if index == question.correctIndex {
correctCount += 1
}
} label: {
HStack {
Text(option)
.font(.body)
.multilineTextAlignment(.leading)
Spacer()
if let selected = selectedOption {
if index == question.correctIndex {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
} else if index == selected {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(optionBackground(index: index, correct: question.correctIndex), in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
Spacer()
// Next button
if selectedOption != nil {
Button {
if currentIndex + 1 < questions.count {
currentIndex += 1
selectedOption = nil
} else {
withAnimation { isFinished = true }
}
} label: {
Text(currentIndex + 1 < questions.count ? "Next Question" : "See Results")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.teal)
}
}
}
// MARK: - Finished View
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: correctCount == questions.count ? "star.fill" : "checkmark.circle")
.font(.system(size: 60))
.foregroundStyle(correctCount == questions.count ? .yellow : .teal)
Text("\(correctCount) / \(questions.count)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text(scoreMessage)
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer()
}
}
private var scoreMessage: String {
switch correctCount {
case questions.count: return "Perfect score!"
case _ where correctCount > questions.count / 2: return "Good job! Keep reading."
default: return "Try re-reading the story and quiz again."
}
}
// MARK: - Helpers
private func optionBackground(index: Int, correct: Int) -> some ShapeStyle {
guard let selected = selectedOption else {
return AnyShapeStyle(.fill.quaternary)
}
if index == correct {
return AnyShapeStyle(.green.opacity(0.15))
}
if index == selected {
return AnyShapeStyle(.red.opacity(0.15))
}
return AnyShapeStyle(.fill.quaternary)
}
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@@ -0,0 +1,333 @@
import SwiftUI
import SharedModels
import FoundationModels
struct StoryReaderView: View {
let story: Story
@Environment(DictionaryService.self) private var dictionary
@State private var selectedWord: WordAnnotation?
@State private var showTranslation = false
@State private var lookupCache: [String: WordAnnotation] = [:]
private var annotations: [WordAnnotation] { story.decodedAnnotations }
private var annotationMap: [String: WordAnnotation] {
Dictionary(annotations.map { (cleanWord($0.word), $0) }, uniquingKeysWith: { first, _ in first })
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Title
Text(story.title)
.font(.title2.bold())
// Level badge
Text(story.level.capitalized)
.font(.caption2.weight(.medium))
.foregroundStyle(.teal)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.teal.opacity(0.12), in: Capsule())
Divider()
// Tappable Spanish text
tappableText
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
// Translation toggle
VStack(alignment: .leading, spacing: 8) {
Button {
withAnimation { showTranslation.toggle() }
} label: {
Label(
showTranslation ? "Hide Translation" : "Show Translation",
systemImage: showTranslation ? "eye.slash" : "eye"
)
.font(.subheadline.weight(.medium))
}
.tint(.secondary)
if showTranslation {
Text(story.bodyEN)
.font(.body)
.foregroundStyle(.secondary)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// Quiz button
if !story.decodedQuestions.isEmpty {
NavigationLink {
StoryQuizView(story: story)
} label: {
Label("Take Comprehension Quiz", systemImage: "questionmark.circle")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.teal)
}
}
.padding()
.adaptiveContainer(maxWidth: 800)
}
.navigationTitle("Story")
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedWord) { word in
WordDetailSheet(word: word)
.presentationDetents([.height(200)])
}
}
// MARK: - Tappable Text
private var tappableText: some View {
let words = story.bodyES.components(separatedBy: " ")
let map = annotationMap
let cache = lookupCache
let context = story.bodyES
return WrappingHStack(words: words) { word in
WordButton(word: word, map: map, cache: cache) { ann in
if ann.english.isEmpty {
lookupWord(ann.word, inContext: context)
} else {
selectedWord = ann
}
}
}
}
private func lookupWord(_ word: String, inContext sentence: String) {
// Try offline dictionary first
if let entry = dictionary.lookup(word) {
let annotation = WordAnnotation(
word: word,
baseForm: entry.baseForm,
english: entry.english,
partOfSpeech: entry.partOfSpeech
)
lookupCache[word] = annotation
selectedWord = annotation
return
}
// Fall back to on-device AI lookup
selectedWord = WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: "")
Task {
do {
let annotation = try await WordLookup.lookup(word: word, inContext: sentence)
lookupCache[word] = annotation
selectedWord = annotation
} catch {
selectedWord = WordAnnotation(word: word, baseForm: word, english: "Lookup unavailable", partOfSpeech: "")
}
}
}
private func cleanWord(_ word: String) -> String {
word.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
}
}
// MARK: - Word Button
private struct WordButton: View {
let word: String
let map: [String: WordAnnotation]
let cache: [String: WordAnnotation]
let onTap: (WordAnnotation) -> Void
private var cleaned: String {
word.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
}
private var resolvedAnnotation: WordAnnotation {
map[cleaned] ?? cache[cleaned] ?? WordAnnotation(word: cleaned, baseForm: cleaned, english: "", partOfSpeech: "")
}
var body: some View {
Button {
onTap(resolvedAnnotation)
} label: {
Text(word + " ")
.font(.body)
.foregroundStyle(.primary)
.underline(true, color: .teal.opacity(0.3))
}
.buttonStyle(.plain)
}
}
// MARK: - Wrapping HStack
private struct WrappingHStack<Content: View>: View {
let words: [String]
let content: (String) -> Content
var body: some View {
FlowLayout(spacing: 0) {
ForEach(Array(words.enumerated()), id: \.offset) { _, word in
content(word)
}
}
.accessibilityElement(children: .combine)
}
}
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()
}
.padding()
}
}
// MARK: - On-Demand Word Lookup
@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
)
}
}

View File

@@ -0,0 +1,180 @@
import SwiftUI
import SharedModels
import SwiftData
struct VocabReviewView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.modelContext) private var localContext
@Environment(\.dismiss) private var dismiss
@State private var dueCards: [CourseReviewCard] = []
@State private var currentIndex = 0
@State private var isRevealed = false
@State private var sessionCorrect = 0
@State private var sessionTotal = 0
@State private var isFinished = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
VStack(spacing: 20) {
if isFinished || dueCards.isEmpty {
finishedView
} else if let card = dueCards[safe: currentIndex] {
cardView(card)
}
}
.padding()
.adaptiveContainer(maxWidth: 600)
.navigationTitle("Vocab Review")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadDueCards)
}
// MARK: - Card View
@ViewBuilder
private func cardView(_ card: CourseReviewCard) -> some View {
VStack(spacing: 24) {
// Progress
Text("\(currentIndex + 1) of \(dueCards.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
.tint(.teal)
Spacer()
// Front (Spanish)
Text(card.front)
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
if isRevealed {
// Back (English)
Text(card.back)
.font(.title2)
.foregroundStyle(.secondary)
.transition(.opacity.combined(with: .move(edge: .bottom)))
Spacer()
// Rating buttons
HStack(spacing: 12) {
ratingButton("Again", color: .red, quality: .again)
ratingButton("Hard", color: .orange, quality: .hard)
ratingButton("Good", color: .green, quality: .good)
ratingButton("Easy", color: .blue, quality: .easy)
}
} else {
Spacer()
Button {
withAnimation { isRevealed = true }
} label: {
Text("Show Answer")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.teal)
}
}
}
// MARK: - Finished View
private var finishedView: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
.font(.system(size: 60))
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
if dueCards.isEmpty {
Text("All caught up!")
.font(.title2.bold())
Text("No vocabulary cards are due for review.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("\(sessionCorrect) / \(sessionTotal)")
.font(.system(size: 48, weight: .bold).monospacedDigit())
Text("Review complete!")
.font(.title3)
.foregroundStyle(.secondary)
}
Spacer()
}
}
// MARK: - Helpers
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
Button {
rate(quality: quality)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.bordered)
.tint(color)
}
private func rate(quality: ReviewQuality) {
guard let card = dueCards[safe: currentIndex] else { return }
let store = CourseReviewStore(context: cloudContext)
let result = SRSEngine.review(
quality: quality,
currentEase: card.easeFactor,
currentInterval: card.interval,
currentReps: card.repetitions
)
card.easeFactor = result.easeFactor
card.interval = result.interval
card.repetitions = result.repetitions
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
card.lastReviewDate = Date()
try? cloudContext.save()
sessionTotal += 1
if quality != .again { sessionCorrect += 1 }
isRevealed = false
if currentIndex + 1 < dueCards.count {
currentIndex += 1
} else {
withAnimation { isFinished = true }
}
}
private func loadDueCards() {
let now = Date()
let descriptor = FetchDescriptor<CourseReviewCard>(
predicate: #Predicate<CourseReviewCard> { $0.dueDate <= now },
sortBy: [SortDescriptor(\CourseReviewCard.dueDate)]
)
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
}
static func dueCount(context: ModelContext) -> Int {
let now = Date()
let descriptor = FetchDescriptor<CourseReviewCard>(
predicate: #Predicate<CourseReviewCard> { $0.dueDate <= now }
)
return (try? context.fetchCount(descriptor)) ?? 0
}
}
private extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@@ -0,0 +1,252 @@
import SwiftUI
struct FeatureReferenceView: View {
var body: some View {
List {
Section("Verb Conjugation Practice") {
featureRow(
icon: "rectangle.stack", color: .blue,
title: "Flashcard / Typing / MC / Handwriting / Sentence Builder",
details: [
"Pulls from verb conjugation database (1,750 verbs)",
"Filtered by your Level setting",
"Filtered by your Enabled Tenses",
"Respects Include Vosotros setting",
"Due cards (SRS) shown first, then random",
]
)
featureRow(
icon: "tablecells", color: .blue,
title: "Full Table",
details: [
"Shows all 6 person forms for one verb + tense",
"Random verb from your Level",
"Random tense from your Enabled Tenses",
]
)
}
Section("Quick Actions") {
featureRow(
icon: "star.fill", color: .orange,
title: "Common Tenses",
details: [
"Restricts to 6 essential tenses: Present, Preterite, Imperfect, Future, Present Subjunctive, Imperative",
"Still filtered by your Level",
]
)
featureRow(
icon: "exclamationmark.triangle", color: .red,
title: "Weak Verbs",
details: [
"Shows verbs you've struggled with (ease factor < 2.0)",
"Only includes verbs you've reviewed at least once",
"Weakest verbs shown first",
]
)
featureRow(
icon: "wand.and.stars", color: .purple,
title: "Irregularity Drills",
details: [
"Spelling Changes: c->qu, g->gu, z->c patterns",
"Stem Changes: e->ie, o->ue, e->i patterns",
"Unique Irregulars: ser, ir, haber, etc.",
"Filtered by your Level and Enabled Tenses",
]
)
featureRow(
icon: "rectangle.stack.fill", color: .teal,
title: "Vocab Review",
details: [
"Reviews vocabulary cards that are due (SRS scheduled)",
"Cards become due after you study them in Course quizzes",
"Rate Again/Hard/Good/Easy to schedule next review",
"Uses all course vocabulary, not filtered by level",
]
)
}
Section("Practice Activities") {
featureRow(
icon: "bubble.left.and.bubble.right.fill", color: .green,
title: "Conversation",
details: [
"AI chat partner using Apple Intelligence (on-device)",
"10 scenario types (restaurant, directions, etc.)",
"AI adapts vocabulary to your Level setting",
"Corrections provided inline when you make mistakes",
"Conversations saved to iCloud for revisiting",
"Requires Apple Intelligence-capable device",
]
)
featureRow(
icon: "ear.fill", color: .blue,
title: "Listening",
details: [
"Listen & Type: hear a sentence, type what you heard",
"Pronunciation: read a sentence aloud, get scored on accuracy",
"Sentences pulled from course vocabulary examples",
"Uses all course vocab (not filtered by level)",
"Pronunciation requires microphone permission",
]
)
featureRow(
icon: "text.badge.minus", color: .indigo,
title: "Cloze Practice",
details: [
"Fill in the missing word in a Spanish sentence",
"Sentences from course vocabulary examples",
"4 multiple-choice options (1 correct + 3 distractors)",
"Distractors are other vocabulary words from same pool",
"Uses all course vocab (not filtered by level)",
]
)
featureRow(
icon: "music.note.list", color: .pink,
title: "Lyrics",
details: [
"Search and save Spanish song lyrics",
"Side-by-side Spanish and English translations",
"User-curated library, not filtered by level",
"Saved to iCloud for sync across devices",
]
)
featureRow(
icon: "book.fill", color: .teal,
title: "Stories",
details: [
"AI-generated one-paragraph Spanish stories",
"Matched to your Level and Enabled Tenses",
"Every word is tappable for definition",
"Known words use offline dictionary (175K+ verb forms)",
"Unknown words looked up via on-device AI",
"English translation hidden by default (toggle to reveal)",
"3-question comprehension quiz at the end",
"Saved to iCloud for revisiting",
"Requires Apple Intelligence-capable device",
]
)
}
Section("Guide") {
featureRow(
icon: "book", color: .brown,
title: "Tense Guides",
details: [
"Detailed explanation of each of the 20 verb tenses",
"Conjugation ending tables for -ar, -er, -ir verbs",
"Usage patterns with example sentences",
"Essential tenses marked with orange badge",
]
)
featureRow(
icon: "doc.text", color: .brown,
title: "Grammar Notes",
details: [
"23 grammar topics (ser vs estar, por vs para, etc.)",
"Interactive exercises available for 5 topics",
"Tap 'Practice This' on notes that have exercises",
"Content grouped by category with card-based layout",
]
)
}
Section("Course") {
featureRow(
icon: "list.clipboard", color: .orange,
title: "Course Quizzes",
details: [
"Vocabulary from specific course weeks",
"Multiple quiz types: MC, typing, handwriting, cloze",
"Focus Area mode for missed words",
"Not filtered by Level (uses course structure)",
]
)
featureRow(
icon: "checkmark.seal", color: .orange,
title: "Checkpoint Exams",
details: [
"Cumulative review across multiple weeks",
"Cards sampled evenly across all covered weeks",
"Choose 25, 50, or 100 questions",
]
)
}
Section("Dashboard") {
featureRow(
icon: "clock.fill", color: .mint,
title: "Study Time",
details: [
"Tracks time the app is in the foreground",
"Starts when app becomes active, stops on background",
"Shows today's time and all-time total",
"7-day bar chart of daily study time",
]
)
}
Section("Settings That Affect Practice") {
settingRow(name: "Level", affects: "Verb practice, Full Table, Quick Actions, Stories, Conversation")
settingRow(name: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories")
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions")
settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")
}
}
.navigationTitle("How Features Work")
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Components
@ViewBuilder
private func featureRow(icon: String, color: Color, title: String, details: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.body)
.foregroundStyle(color)
.frame(width: 24)
Text(title)
.font(.subheadline.weight(.semibold))
}
VStack(alignment: .leading, spacing: 4) {
ForEach(details, id: \.self) { detail in
HStack(alignment: .top, spacing: 6) {
Text("")
.font(.caption)
.foregroundStyle(.secondary)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.leading, 34)
}
.padding(.vertical, 4)
}
@ViewBuilder
private func settingRow(name: String, affects: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(name)
.font(.subheadline.weight(.semibold))
Text(affects)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}

View File

@@ -75,6 +75,12 @@ struct SettingsView: View {
}
}
Section("Reference") {
NavigationLink("How Features Work") {
FeatureReferenceView()
}
}
Section("About") {
LabeledContent("Version", value: "1.0.0")
}

View File

@@ -38,15 +38,30 @@ struct VerbDetailView: View {
ContentUnavailableView("No forms loaded", systemImage: "exclamationmark.triangle")
} else {
ForEach(formsForTense, id: \.personIndex) { form in
HStack {
Text(TenseInfo.persons[form.personIndex])
.foregroundStyle(.secondary)
.frame(minWidth: 100, alignment: .leading)
Text(form.form)
.fontWeight(form.regularity != "ordinary" ? .semibold : .regular)
.foregroundStyle(form.regularity != "ordinary" ? .red : .primary)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(TenseInfo.persons[form.personIndex])
.foregroundStyle(.secondary)
.frame(minWidth: 100, alignment: .leading)
Text(form.form)
.fontWeight(form.regularity != "ordinary" ? .semibold : .regular)
.foregroundStyle(form.regularity != "ordinary" ? .red : .primary)
}
Text(EnglishConjugator.translate(
english: verb.english,
tenseId: selectedTense.id,
personIndex: form.personIndex
))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if formsForTense.contains(where: { $0.regularity != "ordinary" }) {
Label("Red indicates an irregular conjugation", systemImage: "info.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} header: {
Text("Conjugation")

File diff suppressed because one or more lines are too long

View File

@@ -8486,7 +8486,7 @@
"cards": [
{
"front": "tener",
"back": "tengo",
"back": "tengo — I have",
"examples": [
{
"es": "The Spanish Verb \"Tener\"",
@@ -8504,7 +8504,7 @@
},
{
"front": "venir",
"back": "vengo",
"back": "vengo — I come",
"examples": [
{
"es": "Lo mejor está por venir.",
@@ -8522,7 +8522,7 @@
},
{
"front": "hacer",
"back": "hago",
"back": "hago — I do, I make",
"examples": [
{
"es": "Expressions with \"Hacer\"",
@@ -8540,7 +8540,7 @@
},
{
"front": "salir",
"back": "salgo",
"back": "salgo — I go out",
"examples": [
{
"es": "Usa el ascensor para salir.",
@@ -8558,7 +8558,7 @@
},
{
"front": "caer",
"back": "caigo",
"back": "caigo — I fall",
"examples": [
{
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
@@ -8576,7 +8576,7 @@
},
{
"front": "traer",
"back": "traigo",
"back": "traigo — I bring",
"examples": [
{
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
@@ -8594,7 +8594,7 @@
},
{
"front": "poner",
"back": "pongo",
"back": "pongo — I put",
"examples": [
{
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
@@ -8612,7 +8612,7 @@
},
{
"front": "decir",
"back": "digo",
"back": "digo — I say",
"examples": [
{
"es": "¿Jura decir la verdad?",
@@ -8630,7 +8630,7 @@
},
{
"front": "conducir",
"back": "conduzco",
"back": "conduzco — I lead, I drive",
"examples": [
{
"es": "conducir(kohn-doo-seer)",
@@ -8648,7 +8648,7 @@
},
{
"front": "conocer",
"back": "conozco",
"back": "conozco — I know, I meet",
"examples": [
{
"es": "conocer(koh-noh-sehr)",
@@ -8666,7 +8666,7 @@
},
{
"front": "agradecer",
"back": "agradezco",
"back": "agradezco — I thank",
"examples": [
{
"es": "agradecer(ah-grah-deh-sehr)",
@@ -8684,7 +8684,7 @@
},
{
"front": "parecer",
"back": "parezco",
"back": "parezco — I seem",
"examples": [
{
"es": "parecer(pah-reh-sehr)",
@@ -8702,7 +8702,7 @@
},
{
"front": "crecer",
"back": "crezco",
"back": "crezco — I grow",
"examples": [
{
"es": "crecer(kreh-sehr)",
@@ -8720,7 +8720,7 @@
},
{
"front": "producir",
"back": "produzco",
"back": "produzco — I produce",
"examples": [
{
"es": "producir(proh-doo-seer)",
@@ -8738,7 +8738,7 @@
},
{
"front": "traducir",
"back": "traduzco",
"back": "traduzco — I translate",
"examples": [
{
"es": "traducir(trah-doo-seer)",
@@ -8756,7 +8756,7 @@
},
{
"front": "establecer",
"back": "establezco",
"back": "establezco — I establish",
"examples": [
{
"es": "establecer(ehs-tah-bleh-sehr)",
@@ -8774,7 +8774,7 @@
},
{
"front": "elejir",
"back": "elijo",
"back": "elijo — I choose",
"examples": [
{
"es": "En realidad cada persona será libre de elejir su comida.",
@@ -8792,7 +8792,7 @@
},
{
"front": "proteger",
"back": "protejo",
"back": "protejo — I protect",
"examples": [
{
"es": "proteger(proh-teh-hehr)",
@@ -8810,7 +8810,7 @@
},
{
"front": "dirigir",
"back": "dirijo",
"back": "dirijo — I manage, I direct",
"examples": [
{
"es": "dirigir(dee-ree-heer)",
@@ -8828,7 +8828,7 @@
},
{
"front": "fingir",
"back": "finjo",
"back": "finjo — I pretend, I feign",
"examples": [
{
"es": "fingir(feen-heer)",
@@ -8846,7 +8846,7 @@
},
{
"front": "sumergir",
"back": "sumerjo",
"back": "sumerjo — I submerge",
"examples": [
{
"es": "sumergir(soo-mehr-heer)",
@@ -8864,7 +8864,7 @@
},
{
"front": "ver",
"back": "veo",
"back": "veo — I see",
"examples": [
{
"es": "¿Quieres ver mi carro nuevo?",
@@ -8882,7 +8882,7 @@
},
{
"front": "saber",
"back": "sé",
"back": "sé — I know, I taste",
"examples": [
{
"es": "El saber popular se basa en creencias.",
@@ -8900,7 +8900,7 @@
},
{
"front": "distinguir",
"back": "distingo",
"back": "distingo — I distinguish",
"examples": [
{
"es": "distinguir(dees-teeng-geer)",
@@ -8918,7 +8918,7 @@
},
{
"front": "oír",
"back": "oigo",
"back": "oigo — I hear",
"examples": [
{
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",
@@ -8943,7 +8943,7 @@
"cards": [
{
"front": "tener",
"back": "tengo",
"back": "tengo — I have",
"examples": [
{
"es": "The Spanish Verb \"Tener\"",
@@ -8961,7 +8961,7 @@
},
{
"front": "venir",
"back": "vengo",
"back": "vengo — I come",
"examples": [
{
"es": "Lo mejor está por venir.",
@@ -8979,7 +8979,7 @@
},
{
"front": "hacer",
"back": "hago",
"back": "hago — I do, I make",
"examples": [
{
"es": "Expressions with \"Hacer\"",
@@ -8997,7 +8997,7 @@
},
{
"front": "salir",
"back": "salgo",
"back": "salgo — I go out",
"examples": [
{
"es": "Usa el ascensor para salir.",
@@ -9015,7 +9015,7 @@
},
{
"front": "caer",
"back": "caigo",
"back": "caigo — I fall",
"examples": [
{
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
@@ -9033,7 +9033,7 @@
},
{
"front": "traer",
"back": "traigo",
"back": "traigo — I bring",
"examples": [
{
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
@@ -9051,7 +9051,7 @@
},
{
"front": "poner",
"back": "pongo",
"back": "pongo — I put",
"examples": [
{
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
@@ -9069,7 +9069,7 @@
},
{
"front": "decir",
"back": "digo",
"back": "digo — I say",
"examples": [
{
"es": "¿Jura decir la verdad?",
@@ -9087,7 +9087,7 @@
},
{
"front": "conducir",
"back": "conduzco",
"back": "conduzco — I lead, I drive",
"examples": [
{
"es": "conducir(kohn-doo-seer)",
@@ -9105,7 +9105,7 @@
},
{
"front": "conocer",
"back": "conozco",
"back": "conozco — I know, I meet",
"examples": [
{
"es": "conocer(koh-noh-sehr)",
@@ -9123,7 +9123,7 @@
},
{
"front": "agradecer",
"back": "agradezco",
"back": "agradezco — I thank",
"examples": [
{
"es": "agradecer(ah-grah-deh-sehr)",
@@ -9141,7 +9141,7 @@
},
{
"front": "parecer",
"back": "parezco",
"back": "parezco — I seem",
"examples": [
{
"es": "parecer(pah-reh-sehr)",
@@ -9159,7 +9159,7 @@
},
{
"front": "crecer",
"back": "crezco",
"back": "crezco — I grow",
"examples": [
{
"es": "crecer(kreh-sehr)",
@@ -9177,7 +9177,7 @@
},
{
"front": "producir",
"back": "produzco",
"back": "produzco — I produce",
"examples": [
{
"es": "producir(proh-doo-seer)",
@@ -9195,7 +9195,7 @@
},
{
"front": "traducir",
"back": "traduzco",
"back": "traduzco — I translate",
"examples": [
{
"es": "traducir(trah-doo-seer)",
@@ -9213,7 +9213,7 @@
},
{
"front": "establecer",
"back": "establezco",
"back": "establezco — I establish",
"examples": [
{
"es": "establecer(ehs-tah-bleh-sehr)",
@@ -9231,7 +9231,7 @@
},
{
"front": "elejir",
"back": "elijo",
"back": "elijo — I choose",
"examples": [
{
"es": "En realidad cada persona será libre de elejir su comida.",
@@ -9249,7 +9249,7 @@
},
{
"front": "proteger",
"back": "protejo",
"back": "protejo — I protect",
"examples": [
{
"es": "proteger(proh-teh-hehr)",
@@ -9267,7 +9267,7 @@
},
{
"front": "dirigir",
"back": "dirijo",
"back": "dirijo — I manage, I direct",
"examples": [
{
"es": "dirigir(dee-ree-heer)",
@@ -9285,7 +9285,7 @@
},
{
"front": "fingir",
"back": "finjo",
"back": "finjo — I pretend, I feign",
"examples": [
{
"es": "fingir(feen-heer)",
@@ -9303,7 +9303,7 @@
},
{
"front": "sumergir",
"back": "sumerjo",
"back": "sumerjo — I submerge",
"examples": [
{
"es": "sumergir(soo-mehr-heer)",
@@ -9321,7 +9321,7 @@
},
{
"front": "ver",
"back": "veo",
"back": "veo — I see",
"examples": [
{
"es": "¿Quieres ver mi carro nuevo?",
@@ -9339,7 +9339,7 @@
},
{
"front": "saber",
"back": "sé",
"back": "sé — I know, I taste",
"examples": [
{
"es": "El saber popular se basa en creencias.",
@@ -9357,7 +9357,7 @@
},
{
"front": "distinguir",
"back": "distingo",
"back": "distingo — I distinguish",
"examples": [
{
"es": "distinguir(dees-teeng-geer)",
@@ -9375,7 +9375,7 @@
},
{
"front": "oír",
"back": "oigo",
"back": "oigo — I hear",
"examples": [
{
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",

View File

@@ -3,11 +3,15 @@ import PackageDescription
let package = Package(
name: "SharedModels",
platforms: [.iOS(.v18)],
platforms: [.iOS(.v18), .macOS(.v14)],
products: [
.library(name: "SharedModels", targets: ["SharedModels"]),
],
targets: [
.target(name: "SharedModels"),
.testTarget(
name: "SharedModelsTests",
dependencies: ["SharedModels"]
),
]
)

View File

@@ -0,0 +1,48 @@
import SwiftData
import Foundation
@Model
public final class Conversation {
public var id: String = ""
public var scenario: String = ""
public var level: String = ""
public var messages: String = "[]"
public var createdDate: Date = Date()
public init(scenario: String, level: String) {
self.id = UUID().uuidString
self.scenario = scenario
self.level = level
self.messages = "[]"
self.createdDate = Date()
}
}
public struct ChatMessage: Codable, Identifiable, Hashable {
public var id: String
public let role: String // "assistant" or "user"
public let content: String
public let correction: String?
public init(role: String, content: String, correction: String? = nil) {
self.id = UUID().uuidString
self.role = role
self.content = content
self.correction = correction
}
}
extension Conversation {
public var decodedMessages: [ChatMessage] {
guard let data = messages.data(using: .utf8) else { return [] }
return (try? JSONDecoder().decode([ChatMessage].self, from: data)) ?? []
}
public func appendMessage(_ message: ChatMessage) {
var msgs = decodedMessages
msgs.append(message)
if let data = try? JSONEncoder().encode(msgs), let str = String(data: data, encoding: .utf8) {
messages = str
}
}
}

View File

@@ -0,0 +1,246 @@
import Foundation
/// Constructs approximate English translations for Spanish conjugation forms
/// by combining the verb's English infinitive with person pronouns and tense auxiliaries.
///
/// Not perfect for irregular English verbs (gowent, bewas) but covers the
/// common patterns well enough for a learning context.
public enum EnglishConjugator {
public static func translate(english: String, tenseId: String, personIndex: Int) -> String {
let base = english.hasPrefix("to ") ? String(english.dropFirst(3)).trimmingCharacters(in: .whitespaces) : english
guard !base.isEmpty else { return "" }
let pronoun = pronoun(for: personIndex)
switch tenseId {
// Indicative
case "ind_presente":
return "\(pronoun) \(presentForm(base, personIndex: personIndex))"
case "ind_preterito":
return "\(pronoun) \(pastForm(base))"
case "ind_imperfecto":
return "\(pronoun) used to \(base)"
case "ind_futuro":
return "\(pronoun) will \(base)"
case "ind_perfecto":
return "\(pronoun) \(haveForm(personIndex)) \(pastParticiple(base))"
case "ind_pluscuamperfecto":
return "\(pronoun) had \(pastParticiple(base))"
case "ind_futuro_perfecto":
return "\(pronoun) will have \(pastParticiple(base))"
case "ind_preterito_anterior":
return "\(pronoun) had \(pastParticiple(base))"
// Conditional
case "cond_presente":
return "\(pronoun) would \(base)"
case "cond_perfecto":
return "\(pronoun) would have \(pastParticiple(base))"
// Subjunctive
case "subj_presente":
return "that \(pronoun) \(base)"
case "subj_imperfecto_1", "subj_imperfecto_2":
return "that \(pronoun) would \(base)"
case "subj_perfecto":
return "that \(pronoun) \(haveForm(personIndex)) \(pastParticiple(base))"
case "subj_pluscuamperfecto_1", "subj_pluscuamperfecto_2":
return "that \(pronoun) had \(pastParticiple(base))"
case "subj_futuro":
return "that \(pronoun) will \(base)"
case "subj_futuro_perfecto":
return "that \(pronoun) will have \(pastParticiple(base))"
// Imperative
case "imp_afirmativo":
return imperativeAffirmative(base, personIndex: personIndex)
case "imp_negativo":
return "don't \(base)"
default:
return "\(pronoun) \(base)"
}
}
// MARK: - Pronouns
private static func pronoun(for personIndex: Int) -> String {
switch personIndex {
case 0: "I"
case 1: "you"
case 2: "he/she"
case 3: "we"
case 4: "you all"
case 5: "they"
default: ""
}
}
// MARK: - Present tense
private static func presentForm(_ base: String, personIndex: Int) -> String {
// 3rd person singular adds -s/-es
guard personIndex == 2 else { return base }
let words = base.split(separator: " ")
guard let first = words.first else { return base }
let verb = String(first)
let rest = words.dropFirst().joined(separator: " ")
let conjugated = addThirdPersonS(verb)
return rest.isEmpty ? conjugated : "\(conjugated) \(rest)"
}
private static func addThirdPersonS(_ verb: String) -> String {
if verb == "have" { return "has" }
if verb == "be" { return "is" }
if verb == "do" { return "does" }
if verb == "go" { return "goes" }
if verb.hasSuffix("sh") || verb.hasSuffix("ch") || verb.hasSuffix("x") ||
verb.hasSuffix("s") || verb.hasSuffix("z") || verb.hasSuffix("o") {
return verb + "es"
}
if verb.hasSuffix("y") && verb.count > 1 {
let yIndex = verb.index(before: verb.endIndex)
let beforeY = verb[verb.index(before: yIndex)]
if !"aeiou".contains(beforeY) {
return String(verb.dropLast()) + "ies"
}
}
return verb + "s"
}
// MARK: - Past tense
private static func pastForm(_ base: String) -> String {
// Check common irregulars first
let words = base.split(separator: " ")
guard let first = words.first else { return base }
let verb = String(first).lowercased()
let rest = words.dropFirst().joined(separator: " ")
let irregular: String? = commonIrregularPast[verb]
let past = irregular ?? addEd(String(first))
return rest.isEmpty ? past : "\(past) \(rest)"
}
private static func addEd(_ verb: String) -> String {
if verb.hasSuffix("e") { return verb + "d" }
if verb.hasSuffix("y") && verb.count > 1 {
let beforeY = verb[verb.index(before: verb.endIndex)]
if !"aeiou".contains(beforeY) {
return String(verb.dropLast()) + "ied"
}
}
return verb + "ed"
}
// MARK: - Past participle
private static func pastParticiple(_ base: String) -> String {
let words = base.split(separator: " ")
guard let first = words.first else { return base }
let verb = String(first).lowercased()
let rest = words.dropFirst().joined(separator: " ")
let irregular: String? = commonIrregularParticiple[verb]
let participle = irregular ?? addEd(String(first))
return rest.isEmpty ? participle : "\(participle) \(rest)"
}
// MARK: - Gerund
private static func gerund(_ base: String) -> String {
let words = base.split(separator: " ")
guard let first = words.first else { return base }
let verb = String(first)
let rest = words.dropFirst().joined(separator: " ")
let ing: String
if verb.hasSuffix("ie") {
ing = String(verb.dropLast(2)) + "ying"
} else if verb.hasSuffix("e") && !verb.hasSuffix("ee") {
ing = String(verb.dropLast()) + "ing"
} else {
ing = verb + "ing"
}
return rest.isEmpty ? ing : "\(ing) \(rest)"
}
// MARK: - Auxiliaries
private static func haveForm(_ personIndex: Int) -> String {
personIndex == 2 ? "has" : "have"
}
private static func beForm(_ personIndex: Int) -> String {
switch personIndex {
case 0: "am"
case 2: "is"
default: "are"
}
}
// MARK: - Imperative
private static func imperativeAffirmative(_ base: String, personIndex: Int) -> String {
switch personIndex {
case 1, 4: "\(base)!"
case 3: "let's \(base)!"
default: "\(base)!"
}
}
// MARK: - Irregular lookups (most common English irregulars)
private static let commonIrregularPast: [String: String] = [
"be": "was/were", "have": "had", "do": "did", "go": "went",
"say": "said", "get": "got", "make": "made", "know": "knew",
"think": "thought", "take": "took", "come": "came", "see": "saw",
"want": "wanted", "give": "gave", "tell": "told", "find": "found",
"put": "put", "leave": "left", "bring": "brought", "begin": "began",
"keep": "kept", "hold": "held", "write": "wrote", "stand": "stood",
"hear": "heard", "let": "let", "mean": "meant", "set": "set",
"meet": "met", "run": "ran", "pay": "paid", "sit": "sat",
"speak": "spoke", "read": "read", "grow": "grew", "lose": "lost",
"fall": "fell", "feel": "felt", "cut": "cut", "sell": "sold",
"drive": "drove", "buy": "bought", "wear": "wore", "choose": "chose",
"sleep": "slept", "eat": "ate", "drink": "drank", "swim": "swam",
"fly": "flew", "break": "broke", "sing": "sang", "catch": "caught",
"send": "sent", "build": "built", "spend": "spent", "win": "won",
"fight": "fought", "throw": "threw", "teach": "taught", "lead": "led",
"understand": "understood", "draw": "drew", "ride": "rode",
"rise": "rose", "shake": "shook", "forget": "forgot",
"shoot": "shot", "wake": "woke", "bite": "bit", "hide": "hid",
"lay": "laid", "lie": "lay", "strike": "struck", "hang": "hung",
"blow": "blew", "dig": "dug", "feed": "fed", "forgive": "forgave",
"freeze": "froze", "hurt": "hurt", "light": "lit", "shut": "shut",
"steal": "stole", "stick": "stuck", "sweep": "swept",
"swing": "swung", "tear": "tore",
]
private static let commonIrregularParticiple: [String: String] = [
"be": "been", "have": "had", "do": "done", "go": "gone",
"say": "said", "get": "gotten", "make": "made", "know": "known",
"think": "thought", "take": "taken", "come": "come", "see": "seen",
"give": "given", "tell": "told", "find": "found",
"put": "put", "leave": "left", "bring": "brought", "begin": "begun",
"keep": "kept", "hold": "held", "write": "written", "stand": "stood",
"hear": "heard", "let": "let", "mean": "meant", "set": "set",
"meet": "met", "run": "run", "pay": "paid", "sit": "sat",
"speak": "spoken", "read": "read", "grow": "grown", "lose": "lost",
"fall": "fallen", "feel": "felt", "cut": "cut", "sell": "sold",
"drive": "driven", "buy": "bought", "wear": "worn", "choose": "chosen",
"sleep": "slept", "eat": "eaten", "drink": "drunk", "swim": "swum",
"fly": "flown", "break": "broken", "sing": "sung", "catch": "caught",
"send": "sent", "build": "built", "spend": "spent", "win": "won",
"fight": "fought", "throw": "thrown", "teach": "taught", "lead": "led",
"understand": "understood", "draw": "drawn", "ride": "ridden",
"rise": "risen", "shake": "shaken", "forget": "forgotten",
"shoot": "shot", "wake": "woken", "bite": "bitten", "hide": "hidden",
"lay": "laid", "lie": "lain", "strike": "struck", "hang": "hung",
"blow": "blown", "dig": "dug", "feed": "fed", "forgive": "forgiven",
"freeze": "frozen", "hurt": "hurt", "light": "lit", "shut": "shut",
"steal": "stolen", "stick": "stuck", "sweep": "swept",
"swing": "swung", "tear": "torn",
]
}

View File

@@ -0,0 +1,25 @@
import SwiftData
import Foundation
@Model
public final class SavedSong {
public var id: String = ""
public var title: String = ""
public var artist: String = ""
public var lyricsES: String = ""
public var lyricsEN: String = ""
public var albumArtURL: String = ""
public var appleMusicURL: String = ""
public var savedDate: Date = Date()
public init(title: String, artist: String, lyricsES: String, lyricsEN: String, albumArtURL: String = "", appleMusicURL: String = "") {
self.id = UUID().uuidString
self.title = title
self.artist = artist
self.lyricsES = lyricsES
self.lyricsEN = lyricsEN
self.albumArtURL = albumArtURL
self.appleMusicURL = appleMusicURL
self.savedDate = Date()
}
}

View File

@@ -0,0 +1,112 @@
import Foundation
/// Pure logic for the Complete the Sentence quiz type.
///
/// Given a `VocabCard` with example sentences, the engine determines whether a
/// blankable question can be produced and builds the `Question` used by the UI.
/// No SwiftUI dependency exists in SharedModels so it can be unit-tested in
/// isolation and reused by other surfaces.
public struct SentenceQuizEngine {
public struct Question: Equatable, Sendable {
public let sentenceES: String
public let sentenceEN: String
/// The exact substring in `sentenceES` that was blanked (original casing preserved).
public let blankWord: String
/// `sentenceES` with `blankWord` replaced by a visible blank marker.
public let displayTemplate: String
/// Index into the card's `examplesES` that this question was built from.
public let exampleIndex: Int
public init(sentenceES: String, sentenceEN: String, blankWord: String, displayTemplate: String, exampleIndex: Int) {
self.sentenceES = sentenceES
self.sentenceEN = sentenceEN
self.blankWord = blankWord
self.displayTemplate = displayTemplate
self.exampleIndex = exampleIndex
}
}
/// Marker string substituted into `displayTemplate` in place of the blank word.
public static let blankMarker = "_____"
/// True when the card has at least one example sentence where a blank can be determined,
/// either via a stored `examplesBlanks` entry or by substring-matching `card.front`.
public static func hasValidSentence(for card: VocabCard) -> Bool {
guard !card.examplesES.isEmpty else { return false }
for i in card.examplesES.indices {
if isBlankResolvable(card: card, exampleIndex: i) {
return true
}
}
return false
}
/// Returns the set of example indices that can produce a valid blank.
public static func resolvableIndices(for card: VocabCard) -> [Int] {
card.examplesES.indices.filter { isBlankResolvable(card: card, exampleIndex: $0) }
}
/// Builds a question from the card by picking a random resolvable example.
/// Returns nil if no example qualifies.
public static func buildQuestion(for card: VocabCard) -> Question? {
let candidates = resolvableIndices(for: card)
guard let pick = candidates.randomElement() else { return nil }
return buildQuestion(for: card, exampleIndex: pick)
}
/// Deterministic variant builds a question from a specific example index.
/// Returns nil if that example doesn't contain a resolvable blank.
public static func buildQuestion(for card: VocabCard, exampleIndex: Int) -> Question? {
guard exampleIndex >= 0, exampleIndex < card.examplesES.count else { return nil }
let sentence = card.examplesES[exampleIndex]
guard sentence.split(separator: " ").count >= minimumWordCount else { return nil }
let sentenceEN = exampleIndex < card.examplesEN.count ? card.examplesEN[exampleIndex] : ""
let storedBlank = exampleIndex < card.examplesBlanks.count ? card.examplesBlanks[exampleIndex] : ""
// Prefer the stored blank if present and actually appears in the sentence.
if !storedBlank.isEmpty, let range = sentence.range(of: storedBlank, options: .caseInsensitive) {
return makeQuestion(sentence: sentence, sentenceEN: sentenceEN, range: range, exampleIndex: exampleIndex)
}
// Fall back to substring match on card.front.
if !card.front.isEmpty, let range = sentence.range(of: card.front, options: .caseInsensitive) {
return makeQuestion(sentence: sentence, sentenceEN: sentenceEN, range: range, exampleIndex: exampleIndex)
}
return nil
}
// MARK: - Private
/// Minimum number of whitespace-separated tokens for an example to count as
/// a real sentence (filters out phonetic glosses like "discutir(dees-koo-teer)").
public static let minimumWordCount = 4
private static func isBlankResolvable(card: VocabCard, exampleIndex: Int) -> Bool {
let sentence = card.examplesES[exampleIndex]
guard sentence.split(separator: " ").count >= minimumWordCount else { return false }
let storedBlank = exampleIndex < card.examplesBlanks.count ? card.examplesBlanks[exampleIndex] : ""
if !storedBlank.isEmpty, sentence.range(of: storedBlank, options: .caseInsensitive) != nil {
return true
}
if !card.front.isEmpty, sentence.range(of: card.front, options: .caseInsensitive) != nil {
return true
}
return false
}
private static func makeQuestion(sentence: String, sentenceEN: String, range: Range<String.Index>, exampleIndex: Int) -> Question {
let blankWord = String(sentence[range])
var template = sentence
template.replaceSubrange(range, with: blankMarker)
return Question(
sentenceES: sentence,
sentenceEN: sentenceEN,
blankWord: blankWord,
displayTemplate: template,
exampleIndex: exampleIndex
)
}
}

View File

@@ -0,0 +1,67 @@
import SwiftData
import Foundation
@Model
public final class Story {
public var id: String = ""
public var title: String = ""
public var bodyES: String = ""
public var bodyEN: String = ""
public var level: String = ""
public var wordAnnotations: String = "[]"
public var quizQuestions: String = "[]"
public var createdDate: Date = Date()
public init(title: String, bodyES: String, bodyEN: String, level: String, wordAnnotations: String, quizQuestions: String) {
self.id = UUID().uuidString
self.title = title
self.bodyES = bodyES
self.bodyEN = bodyEN
self.level = level
self.wordAnnotations = wordAnnotations
self.quizQuestions = quizQuestions
self.createdDate = Date()
}
}
// MARK: - JSON Helpers
public struct WordAnnotation: Codable, Identifiable, Hashable {
public var id: String { word }
public let word: String
public let baseForm: String
public let english: String
public let partOfSpeech: String
public init(word: String, baseForm: String, english: String, partOfSpeech: String) {
self.word = word
self.baseForm = baseForm
self.english = english
self.partOfSpeech = partOfSpeech
}
}
public struct QuizQuestion: Codable, Identifiable, Hashable {
public var id: String { question }
public let question: String
public let options: [String]
public let correctIndex: Int
public init(question: String, options: [String], correctIndex: Int) {
self.question = question
self.options = options
self.correctIndex = correctIndex
}
}
extension Story {
public var decodedAnnotations: [WordAnnotation] {
guard let data = wordAnnotations.data(using: .utf8) else { return [] }
return (try? JSONDecoder().decode([WordAnnotation].self, from: data)) ?? []
}
public var decodedQuestions: [QuizQuestion] {
guard let data = quizQuestions.data(using: .utf8) else { return [] }
return (try? JSONDecoder().decode([QuizQuestion].self, from: data)) ?? []
}
}

View File

@@ -8,6 +8,9 @@ public final class VocabCard {
public var deckId: String = ""
public var examplesES: [String] = []
public var examplesEN: [String] = []
/// Per-example blank word for Complete the Sentence quiz. Index-aligned with `examplesES`.
/// Empty string at a given index means "fall back to substring-matching card.front".
public var examplesBlanks: [String] = []
public var deck: CourseDeck?
@@ -18,11 +21,12 @@ public final class VocabCard {
public var dueDate: Date = Date()
public var lastReviewDate: Date?
public init(front: String, back: String, deckId: String, examplesES: [String] = [], examplesEN: [String] = []) {
public init(front: String, back: String, deckId: String, examplesES: [String] = [], examplesEN: [String] = [], examplesBlanks: [String] = []) {
self.front = front
self.back = back
self.deckId = deckId
self.examplesES = examplesES
self.examplesEN = examplesEN
self.examplesBlanks = examplesBlanks
}
}

View File

@@ -0,0 +1,166 @@
import Testing
import Foundation
@testable import SharedModels
/// Invariants that the shipped `course_data.json` must satisfy for the
/// Complete the Sentence quiz to work for every card in every course.
///
/// These tests read the repo's `course_data.json` from a fixed relative path.
/// They act as the pass/fail oracle for the content gap-fill work: they fail
/// before the gap-fill pass is complete and pass once every card has at least
/// three examples and at least one of them yields a resolvable blank.
@Suite("Content coverage — course_data.json")
struct ContentCoverageTests {
// Repo-relative path from this test file to the bundled data file.
// SharedModels/Tests/SharedModelsTests/ContentCoverageTests.swift
// ../../../../Conjuga/course_data.json
private static let courseDataPath: String = {
let here = URL(fileURLWithPath: #filePath)
return here
.deletingLastPathComponent() // SharedModelsTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // SharedModels
.deletingLastPathComponent() // Conjuga (repo package parent)
.appendingPathComponent("Conjuga/course_data.json")
.path
}()
struct CardRef {
let courseName: String
let weekNumber: Int
let deckTitle: String
let front: String
let back: String
let examples: [[String: String]]
}
/// Load every card in course_data.json.
static func loadAllCards() throws -> [CardRef] {
let url = URL(fileURLWithPath: courseDataPath)
let data = try Data(contentsOf: url)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let courses = json["courses"] as? [[String: Any]] else {
Issue.record("course_data.json is not in the expected shape")
return []
}
var cards: [CardRef] = []
for course in courses {
let cname = course["course"] as? String ?? "<unknown>"
let weeks = course["weeks"] as? [[String: Any]] ?? []
for week in weeks {
let wnum = week["week"] as? Int ?? -1
let decks = week["decks"] as? [[String: Any]] ?? []
for deck in decks {
let title = deck["title"] as? String ?? "<unknown>"
let rawCards = deck["cards"] as? [[String: Any]] ?? []
for raw in rawCards {
let front = raw["front"] as? String ?? ""
let back = raw["back"] as? String ?? ""
let examples = (raw["examples"] as? [[String: String]]) ?? []
cards.append(CardRef(
courseName: cname,
weekNumber: wnum,
deckTitle: title,
front: front,
back: back,
examples: examples
))
}
}
}
}
return cards
}
private static func vocabCard(from ref: CardRef) -> VocabCard {
var exES: [String] = []
var exEN: [String] = []
var exBlanks: [String] = []
for ex in ref.examples {
if let es = ex["es"] {
exES.append(es)
exEN.append(ex["en"] ?? "")
exBlanks.append(ex["blank"] ?? "")
}
}
return VocabCard(
front: ref.front,
back: ref.back,
deckId: "\(ref.courseName)_w\(ref.weekNumber)_\(ref.deckTitle)",
examplesES: exES,
examplesEN: exEN,
examplesBlanks: exBlanks
)
}
@Test("course_data.json exists and parses")
func fileExists() throws {
let cards = try Self.loadAllCards()
#expect(cards.count > 0, "Expected at least one card in course_data.json")
}
@Test("Every card has at least three example sentences")
func everyCardHasThreeExamples() throws {
let cards = try Self.loadAllCards()
var failures: [String] = []
for ref in cards {
if ref.examples.count < 3 {
failures.append("[\(ref.courseName) / w\(ref.weekNumber) / \(ref.deckTitle)] '\(ref.front)' has \(ref.examples.count) examples")
}
}
if !failures.isEmpty {
let head = Array(failures.prefix(10)).joined(separator: "\n")
Issue.record("\(failures.count) cards have fewer than 3 examples. First 10:\n\(head)")
}
#expect(failures.isEmpty)
}
@Test("Every card yields a resolvable SentenceQuizEngine question")
func everyCardHasBlankableSentence() throws {
let cards = try Self.loadAllCards()
var failures: [String] = []
for ref in cards {
let card = Self.vocabCard(from: ref)
if !SentenceQuizEngine.hasValidSentence(for: card) {
failures.append("[\(ref.courseName) / w\(ref.weekNumber) / \(ref.deckTitle)] '\(ref.front)'")
}
}
if !failures.isEmpty {
let head = Array(failures.prefix(15)).joined(separator: "\n")
Issue.record("\(failures.count) cards have no resolvable sentence for Complete the Sentence. First 15:\n\(head)")
}
#expect(failures.isEmpty)
}
@Test("Every generated question has a non-empty blank word and display template")
func questionIntegrity() throws {
let cards = try Self.loadAllCards()
var failures: [String] = []
for ref in cards {
let card = Self.vocabCard(from: ref)
// Try to build a question from each resolvable index deterministically
for idx in SentenceQuizEngine.resolvableIndices(for: card) {
guard let q = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: idx) else {
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) returned nil despite being resolvable")
continue
}
if q.blankWord.isEmpty {
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) has empty blankWord")
}
if !q.displayTemplate.contains(SentenceQuizEngine.blankMarker) {
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) display template missing blank marker")
}
if q.displayTemplate == q.sentenceES {
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) display template unchanged from sentence")
}
}
}
if !failures.isEmpty {
let head = Array(failures.prefix(10)).joined(separator: "\n")
Issue.record("\(failures.count) question integrity failures. First 10:\n\(head)")
}
#expect(failures.isEmpty)
}
}

View File

@@ -0,0 +1,268 @@
import Testing
@testable import SharedModels
@Suite("EnglishConjugator")
struct EnglishConjugatorTests {
// MARK: - haber (to have) irregular English verb
@Test("haber present: I have / you have / he/she has")
func haberPresent() {
#expect(t("to have", "ind_presente", 0) == "I have")
#expect(t("to have", "ind_presente", 1) == "you have")
#expect(t("to have", "ind_presente", 2) == "he/she has")
#expect(t("to have", "ind_presente", 3) == "we have")
#expect(t("to have", "ind_presente", 5) == "they have")
}
@Test("haber preterite: I had")
func haberPreterite() {
#expect(t("to have", "ind_preterito", 0) == "I had")
#expect(t("to have", "ind_preterito", 2) == "he/she had")
}
@Test("haber future: I will have")
func haberFuture() {
#expect(t("to have", "ind_futuro", 0) == "I will have")
#expect(t("to have", "ind_futuro", 3) == "we will have")
}
@Test("haber conditional: I would have")
func haberConditional() {
#expect(t("to have", "cond_presente", 0) == "I would have")
}
@Test("haber present perfect: I have had / he/she has had")
func haberPresentPerfect() {
#expect(t("to have", "ind_perfecto", 0) == "I have had")
#expect(t("to have", "ind_perfecto", 2) == "he/she has had")
}
// MARK: - ir (to go) irregular English verb
@Test("ir present: I go / he/she goes")
func irPresent() {
#expect(t("to go", "ind_presente", 0) == "I go")
#expect(t("to go", "ind_presente", 2) == "he/she goes")
#expect(t("to go", "ind_presente", 5) == "they go")
}
@Test("ir preterite: I went")
func irPreterite() {
#expect(t("to go", "ind_preterito", 0) == "I went")
#expect(t("to go", "ind_preterito", 2) == "he/she went")
}
@Test("ir imperfect: I used to go")
func irImperfect() {
#expect(t("to go", "ind_imperfecto", 0) == "I used to go")
}
@Test("ir present perfect: I have gone")
func irPresentPerfect() {
#expect(t("to go", "ind_perfecto", 0) == "I have gone")
#expect(t("to go", "ind_perfecto", 2) == "he/she has gone")
}
// MARK: - ser (to be) most irregular English verb
@Test("ser present: he/she is")
func serPresent() {
#expect(t("to be", "ind_presente", 2) == "he/she is")
}
@Test("ser preterite: I was/were")
func serPreterite() {
#expect(t("to be", "ind_preterito", 0) == "I was/were")
}
@Test("ser present perfect: I have been")
func serPresentPerfect() {
#expect(t("to be", "ind_perfecto", 0) == "I have been")
}
// MARK: - hablar (to speak)
@Test("hablar present: I speak / he/she speaks")
func hablarPresent() {
#expect(t("to speak", "ind_presente", 0) == "I speak")
#expect(t("to speak", "ind_presente", 2) == "he/she speaks")
}
@Test("hablar preterite: I spoke")
func hablarPreterite() {
#expect(t("to speak", "ind_preterito", 0) == "I spoke")
}
@Test("hablar present perfect: I have spoken")
func hablarPresentPerfect() {
#expect(t("to speak", "ind_perfecto", 0) == "I have spoken")
}
// MARK: - comer (to eat)
@Test("comer preterite: I ate")
func comerPreterite() {
#expect(t("to eat", "ind_preterito", 0) == "I ate")
}
@Test("comer present perfect: I have eaten")
func comerPresentPerfect() {
#expect(t("to eat", "ind_perfecto", 0) == "I have eaten")
}
// MARK: - vivir (to live) regular English verb
@Test("vivir present: I live / he/she lives")
func vivirPresent() {
#expect(t("to live", "ind_presente", 0) == "I live")
#expect(t("to live", "ind_presente", 2) == "he/she lives")
}
@Test("vivir preterite: I lived")
func vivirPreterite() {
#expect(t("to live", "ind_preterito", 0) == "I lived")
}
// MARK: - abatir (to knock down) multi-word verb
@Test("abatir present: I knock down / he/she knocks down")
func abatirPresent() {
#expect(t("to knock down", "ind_presente", 0) == "I knock down")
#expect(t("to knock down", "ind_presente", 2) == "he/she knocks down")
}
@Test("abatir conditional: I would knock down")
func abatirConditional() {
#expect(t("to knock down", "cond_presente", 0) == "I would knock down")
#expect(t("to knock down", "cond_presente", 2) == "he/she would knock down")
}
@Test("abatir preterite: I knocked down")
func abatirPreterite() {
#expect(t("to knock down", "ind_preterito", 0) == "I knocked down")
}
// MARK: - Conditional
@Test("conditional: I would speak")
func conditional() {
#expect(t("to speak", "cond_presente", 0) == "I would speak")
#expect(t("to speak", "cond_presente", 2) == "he/she would speak")
}
@Test("conditional perfect: I would have gone")
func conditionalPerfect() {
#expect(t("to go", "cond_perfecto", 0) == "I would have gone")
}
// MARK: - Subjunctive
@Test("present subjunctive: that I speak")
func presentSubjunctive() {
#expect(t("to speak", "subj_presente", 0) == "that I speak")
#expect(t("to speak", "subj_presente", 2) == "that he/she speak")
}
@Test("imperfect subjunctive (ra): that I would speak")
func imperfectSubjunctive1() {
#expect(t("to speak", "subj_imperfecto_1", 0) == "that I would speak")
}
@Test("imperfect subjunctive (se): that I would speak")
func imperfectSubjunctive2() {
#expect(t("to speak", "subj_imperfecto_2", 0) == "that I would speak")
}
@Test("subjunctive perfect: that I have spoken")
func subjunctivePerfect() {
#expect(t("to speak", "subj_perfecto", 0) == "that I have spoken")
#expect(t("to speak", "subj_perfecto", 2) == "that he/she has spoken")
}
@Test("subjunctive pluperfect: that I had gone")
func subjunctivePluperfect() {
#expect(t("to go", "subj_pluscuamperfecto_1", 0) == "that I had gone")
#expect(t("to go", "subj_pluscuamperfecto_2", 0) == "that I had gone")
}
@Test("subjunctive future: that I will speak")
func subjunctiveFuture() {
#expect(t("to speak", "subj_futuro", 0) == "that I will speak")
}
@Test("subjunctive future perfect: that I will have spoken")
func subjunctiveFuturePerfect() {
#expect(t("to speak", "subj_futuro_perfecto", 0) == "that I will have spoken")
}
// MARK: - Imperative
@Test("imperative affirmative")
func imperativeAffirmative() {
#expect(t("to speak", "imp_afirmativo", 1) == "speak!")
#expect(t("to speak", "imp_afirmativo", 3) == "let's speak!")
}
@Test("imperative negative")
func imperativeNegative() {
#expect(t("to speak", "imp_negativo", 1) == "don't speak")
}
// MARK: - Compound indicative tenses
@Test("pluperfect: I had spoken")
func pluperfect() {
#expect(t("to speak", "ind_pluscuamperfecto", 0) == "I had spoken")
#expect(t("to go", "ind_pluscuamperfecto", 0) == "I had gone")
}
@Test("future perfect: I will have spoken")
func futurePerfect() {
#expect(t("to speak", "ind_futuro_perfecto", 0) == "I will have spoken")
}
@Test("preterite anterior: I had spoken (same as pluperfect in English)")
func preteriteAnterior() {
#expect(t("to speak", "ind_preterito_anterior", 0) == "I had spoken")
}
// MARK: - Edge cases
@Test("empty english returns empty string")
func emptyEnglish() {
#expect(t("", "ind_presente", 0) == "")
#expect(t("to ", "ind_presente", 0) == "")
}
@Test("unknown tense falls back to pronoun + base")
func unknownTense() {
#expect(t("to speak", "some_future_tense", 0) == "I speak")
}
@Test("3rd person present: study → studies")
func thirdPersonYRule() {
#expect(t("to study", "ind_presente", 2) == "he/she studies")
}
@Test("3rd person present: play → plays")
func thirdPersonVowelY() {
#expect(t("to play", "ind_presente", 2) == "he/she plays")
}
@Test("3rd person present: watch → watches")
func thirdPersonChRule() {
#expect(t("to watch", "ind_presente", 2) == "he/she watches")
}
@Test("past regular: carry → carried")
func pastYRule() {
#expect(t("to carry", "ind_preterito", 0) == "I carried")
}
// MARK: - Helper
private func t(_ english: String, _ tenseId: String, _ personIndex: Int) -> String {
EnglishConjugator.translate(english: english, tenseId: tenseId, personIndex: personIndex)
}
}

View File

@@ -0,0 +1,289 @@
import Testing
@testable import SharedModels
@Suite("SentenceQuizEngine")
struct SentenceQuizEngineTests {
// MARK: - hasValidSentence
@Test("No examples returns false")
func noExamples() {
let card = VocabCard(front: "comer", back: "to eat", deckId: "d", examplesES: [], examplesEN: [])
#expect(SentenceQuizEngine.hasValidSentence(for: card) == false)
}
@Test("Example containing target word returns true via substring fallback")
func substringMatch() {
let card = VocabCard(
front: "manzana",
back: "apple",
deckId: "d",
examplesES: ["Yo como una manzana roja."],
examplesEN: ["I eat a red apple."]
)
#expect(SentenceQuizEngine.hasValidSentence(for: card))
}
@Test("Example whose stored blank appears returns true even if target word is missing")
func storedBlankMatch() {
let card = VocabCard(
front: "comer",
back: "to eat",
deckId: "d",
examplesES: ["Yo como manzanas todos los días."],
examplesEN: ["I eat apples every day."],
examplesBlanks: ["como"]
)
#expect(SentenceQuizEngine.hasValidSentence(for: card))
}
@Test("Example with neither stored blank nor substring match returns false for that example")
func neitherMatches() {
let card = VocabCard(
front: "comer",
back: "to eat",
deckId: "d",
examplesES: ["Ella prepara la cena."],
examplesEN: ["She prepares dinner."]
)
#expect(SentenceQuizEngine.hasValidSentence(for: card) == false)
}
@Test("At least one resolvable example across many makes the card valid")
func oneOfManyResolves() {
let card = VocabCard(
front: "comer",
back: "to eat",
deckId: "d",
examplesES: [
"Ella prepara la cena.",
"Los niños van al parque.",
"Quiero comer algo ahora."
],
examplesEN: ["", "", ""]
)
#expect(SentenceQuizEngine.hasValidSentence(for: card))
#expect(SentenceQuizEngine.resolvableIndices(for: card) == [2])
}
@Test("Phonetic glosses are rejected (too few words)")
func phoneticGlossRejected() {
let card = VocabCard(
front: "discutir",
back: "to discuss",
deckId: "d",
examplesES: [
"discutir(dees-koo-teer)",
"INTRANSITIVE VERB",
"Los amigos van a discutir el tema."
],
examplesEN: ["", "", "The friends are going to discuss the topic."]
)
// Only index 2 is a real sentence (4 words AND contains the target)
#expect(SentenceQuizEngine.hasValidSentence(for: card))
#expect(SentenceQuizEngine.resolvableIndices(for: card) == [2])
// Phonetic entry at index 0 returns nil
#expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) == nil)
}
// MARK: - buildQuestion (deterministic)
@Test("Builds question from substring match, preserves original casing")
func buildFromSubstring() {
let card = VocabCard(
front: "manzana",
back: "apple",
deckId: "d",
examplesES: ["Yo como una manzana roja."],
examplesEN: ["I eat a red apple."]
)
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
#expect(question != nil)
#expect(question?.sentenceES == "Yo como una manzana roja.")
#expect(question?.sentenceEN == "I eat a red apple.")
#expect(question?.blankWord == "manzana")
#expect(question?.displayTemplate == "Yo como una _____ roja.")
#expect(question?.exampleIndex == 0)
}
@Test("Builds question from stored blank when provided")
func buildFromStoredBlank() {
let card = VocabCard(
front: "comer",
back: "to eat",
deckId: "d",
examplesES: ["Yo como manzanas todos los días."],
examplesEN: ["I eat apples every day."],
examplesBlanks: ["como"]
)
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
#expect(question?.blankWord == "como")
#expect(question?.displayTemplate == "Yo _____ manzanas todos los días.")
}
@Test("Stored blank takes precedence over substring match")
func storedBlankWins() {
// Card teaches "manzana" (would substring-match), but the stored blank is the verb "como"
let card = VocabCard(
front: "manzana",
back: "apple",
deckId: "d",
examplesES: ["Yo como una manzana."],
examplesEN: ["I eat an apple."],
examplesBlanks: ["como"]
)
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
#expect(question?.blankWord == "como")
#expect(question?.displayTemplate == "Yo _____ una manzana.")
}
@Test("Falls back to substring match when stored blank is empty")
func fallbackWhenStoredBlankEmpty() {
let card = VocabCard(
front: "manzana",
back: "apple",
deckId: "d",
examplesES: ["Yo como una manzana."],
examplesEN: ["I eat an apple."],
examplesBlanks: [""]
)
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
#expect(question?.blankWord == "manzana")
}
@Test("Falls back to substring match when stored blank doesn't actually appear in the sentence")
func fallbackWhenStoredBlankMissing() {
let card = VocabCard(
front: "manzana",
back: "apple",
deckId: "d",
examplesES: ["Yo como una manzana."],
examplesEN: ["I eat an apple."],
examplesBlanks: ["nonexistent"]
)
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
#expect(question?.blankWord == "manzana")
}
@Test("Preserves original capitalization when blanking (substring is case-insensitive)")
func preservesCapitalization() {
let card = VocabCard(
front: "hola",
back: "hello",
deckId: "d",
examplesES: ["Hola amiga, ¿cómo estás hoy?"],
examplesEN: ["Hello friend, how are you today?"]
)
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
#expect(question?.blankWord == "Hola")
#expect(question?.displayTemplate == "_____ amiga, ¿cómo estás hoy?")
}
@Test("Blanks phrase cards when target front contains spaces")
func phraseCardBlank() {
let card = VocabCard(
front: "¿cómo estás?",
back: "how are you?",
deckId: "d",
examplesES: ["Hola amiga, ¿cómo estás? Estoy bien."],
examplesEN: ["Hi friend, how are you? I am well."]
)
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
#expect(question?.blankWord == "¿cómo estás?")
#expect(question?.displayTemplate == "Hola amiga, _____ Estoy bien.")
}
@Test("Returns nil when the example has no resolvable blank")
func unresolvableExampleReturnsNil() {
let card = VocabCard(
front: "comer",
back: "to eat",
deckId: "d",
examplesES: ["Ella prepara la cena."],
examplesEN: ["She prepares dinner."]
)
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
#expect(question == nil)
}
@Test("Returns nil when example index is out of range")
func outOfRangeIndexReturnsNil() {
let card = VocabCard(
front: "comer",
back: "to eat",
deckId: "d",
examplesES: ["Yo como."],
examplesEN: [""]
)
#expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 5) == nil)
#expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: -1) == nil)
}
// MARK: - buildQuestion (random)
@Test("Random buildQuestion always picks a resolvable example")
func randomPickIsResolvable() {
let card = VocabCard(
front: "comer",
back: "to eat",
deckId: "d",
examplesES: [
"Ella prepara la cena.", // unresolvable (no "comer")
"Los niños van al parque.", // unresolvable
"Quiero comer algo rico ahora.", // resolvable (substring, 4 words)
"El perro come su comida diaria." // unresolvable "come" but not "comer"
],
examplesEN: ["", "", "", ""]
)
// Only index 2 is resolvable (contains "comer" literally and has 4 words)
for _ in 0..<25 {
let q = SentenceQuizEngine.buildQuestion(for: card)
#expect(q?.exampleIndex == 2)
}
}
@Test("Random buildQuestion returns nil when no examples resolve")
func randomNilWhenNothingResolves() {
let card = VocabCard(
front: "comer",
back: "to eat",
deckId: "d",
examplesES: ["Ella prepara la cena."],
examplesEN: [""]
)
#expect(SentenceQuizEngine.buildQuestion(for: card) == nil)
}
// MARK: - Array alignment edge cases
@Test("examplesBlanks shorter than examplesES is handled gracefully")
func blanksArrayShorterThanExamples() {
let card = VocabCard(
front: "comer",
back: "to eat",
deckId: "d",
examplesES: ["Yo como mucho pan.", "Tú comes en casa."],
examplesEN: ["I eat a lot of bread.", "You eat at home."],
examplesBlanks: ["como"] // only covers index 0
)
// Index 0: stored blank match
let q0 = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
#expect(q0?.blankWord == "como")
// Index 1: no stored blank, "comer" doesn't appear literally unresolvable
let q1 = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 1)
#expect(q1 == nil)
}
@Test("Display template uses the engine's blank marker constant")
func blankMarkerConstant() {
let card = VocabCard(
front: "perro",
back: "dog",
deckId: "d",
examplesES: ["El perro ladra todo el día."],
examplesEN: ["The dog barks all day."]
)
let q = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
#expect(q?.displayTemplate.contains(SentenceQuizEngine.blankMarker) == true)
}
}