Initial commit: Conjuga Spanish conjugation app

Includes SwiftData dual-store architecture (local reference + CloudKit user data),
JSON-based data seeding, 20 tense guides, 20 grammar notes, SRS review system,
course vocabulary, and widget support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-09 20:58:33 -05:00
commit 4b467ec136
95 changed files with 82599 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Xcode
build/
DerivedData/
*.xcodeproj/xcuserdata/
*.xcworkspace/xcuserdata/
*.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
xcuserdata/
# Swift Package Manager
.build/
.swiftpm/
Packages/
# macOS
.DS_Store
*.swp
*~
# CocoaPods
Pods/
# Secrets / env
.env
*.p12
*.mobileprovision
# Archives
*.xcarchive
# Claude
.claude/
# Reference/research docs (not part of the app)
screens/
conjugato/
conjuu-es/

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

View File

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<radialGradient id="glow" cx="50%" cy="45%" r="50%">
<stop offset="0%" stop-color="#F97316" stop-opacity="0.1"/>
<stop offset="55%" stop-color="#F97316" stop-opacity="0"/>
</radialGradient>
<linearGradient id="line" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#F97316" stop-opacity="0"/>
<stop offset="50%" stop-color="#F97316" stop-opacity="1"/>
<stop offset="100%" stop-color="#F97316" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="1024" height="1024" fill="#0C0A09"/>
<!-- Glow -->
<rect width="1024" height="1024" fill="url(#glow)"/>
<!-- Formula: habl + o -->
<text x="340" y="430" font-family="'JetBrains Mono','SF Mono','Menlo',monospace" font-size="120" font-weight="700" fill="#F97316">habl</text>
<text x="710" y="430" font-family="'JetBrains Mono','SF Mono','Menlo',monospace" font-size="90" font-weight="700" fill="#44403c">+</text>
<text x="780" y="430" font-family="'JetBrains Mono','SF Mono','Menlo',monospace" font-size="120" font-weight="700" fill="#22D3EE">o</text>
<!-- Result -->
<text x="512" y="620" font-family="'Playfair Display',Georgia,serif" font-size="260" font-weight="900" fill="white" text-anchor="middle" letter-spacing="-10">hablo</text>
<!-- Glow line -->
<rect x="372" y="670" width="280" height="8" rx="4" fill="url(#line)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,474 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conjuga — Icon #5 Variations</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Playfair+Display:wght@700;900&family=JetBrains+Mono:wght@500;700;800&family=DM+Serif+Display&family=Instrument+Serif&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
}
h1 {
font-family: 'Instrument Serif', serif;
font-size: 2.6rem;
margin-bottom: 6px;
letter-spacing: -1px;
}
.subtitle { color: #666; font-size: 1rem; margin-bottom: 60px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 48px;
max-width: 1500px;
width: 100%;
}
.option {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.icon-wrap {
width: 200px;
height: 200px;
border-radius: 44px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.06);
position: relative;
}
.label { text-align: center; }
.label h3 { font-family: 'DM Serif Display', serif; font-size: 1.3rem; margin-bottom: 4px; }
.label p { color: #888; font-size: 0.85rem; max-width: 260px; }
/* ═══════════════════════════════════════
5A: Original refined — tighter, cleaner
═══════════════════════════════════════ */
.icon-5a {
background: #0C0A09;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.icon-5a::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 45%, rgba(249,115,22,0.1) 0%, transparent 55%);
}
.icon-5a .formula {
display: flex;
align-items: baseline;
gap: 4px;
z-index: 1;
margin-bottom: 8px;
}
.icon-5a .stem {
font-family: 'JetBrains Mono', monospace;
font-size: 24px;
font-weight: 700;
color: #F97316;
}
.icon-5a .op {
font-family: 'JetBrains Mono', monospace;
font-size: 18px;
color: #44403c;
}
.icon-5a .ending {
font-family: 'JetBrains Mono', monospace;
font-size: 24px;
font-weight: 700;
color: #22D3EE;
}
.icon-5a .result {
font-family: 'Playfair Display', serif;
font-size: 52px;
font-weight: 900;
color: white;
z-index: 1;
letter-spacing: -2px;
}
.icon-5a .glow {
width: 80px;
height: 2px;
background: linear-gradient(90deg, transparent, #F97316, transparent);
margin-top: 8px;
z-index: 1;
}
/* ═══════════════════════════════════════
5B: Ember glow — warm radial behind result
═══════════════════════════════════════ */
.icon-5b {
background: #0C0A09;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.icon-5b::before {
content: '';
position: absolute;
width: 160px;
height: 160px;
border-radius: 50%;
background: radial-gradient(circle, rgba(249,115,22,0.2) 0%, rgba(234,88,12,0.08) 40%, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.icon-5b .formula {
display: flex;
align-items: baseline;
gap: 3px;
z-index: 1;
margin-bottom: 4px;
opacity: 0.7;
}
.icon-5b .stem {
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
font-weight: 500;
color: #FB923C;
}
.icon-5b .op {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: #57534e;
}
.icon-5b .ending {
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
font-weight: 500;
color: #67E8F9;
}
.icon-5b .result {
font-family: 'Playfair Display', serif;
font-size: 58px;
font-weight: 900;
color: white;
z-index: 1;
letter-spacing: -2px;
text-shadow: 0 0 40px rgba(249,115,22,0.3);
}
.icon-5b .brand {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: #44403c;
letter-spacing: 5px;
text-transform: uppercase;
margin-top: 10px;
z-index: 1;
}
/* ═══════════════════════════════════════
5C: Split color — stem orange, ending cyan
The result word is split-colored
═══════════════════════════════════════ */
.icon-5c {
background: linear-gradient(170deg, #0f0d0c, #1a1412);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.icon-5c::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #F97316, #22D3EE);
}
.icon-5c .top-line {
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, #F97316, #22D3EE);
}
.icon-5c .result {
z-index: 1;
font-family: 'Playfair Display', serif;
font-size: 56px;
font-weight: 900;
letter-spacing: -2px;
line-height: 1;
}
.icon-5c .result .s { color: #FB923C; }
.icon-5c .result .e { color: #22D3EE; }
.icon-5c .formula {
display: flex;
align-items: center;
gap: 6px;
z-index: 1;
margin-top: 10px;
}
.icon-5c .formula span {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.icon-5c .formula .tag-s {
color: #FB923C;
border: 1px solid rgba(249,115,22,0.3);
}
.icon-5c .formula .tag-plus {
color: #57534e;
}
.icon-5c .formula .tag-e {
color: #22D3EE;
border: 1px solid rgba(34,211,238,0.3);
}
/* ═══════════════════════════════════════
5D: Minimal mono — stark, ultra-clean
Just the formula, nothing else
═══════════════════════════════════════ */
.icon-5d {
background: #000;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.icon-5d .content {
display: flex;
align-items: baseline;
gap: 2px;
z-index: 1;
}
.icon-5d .stem {
font-family: 'JetBrains Mono', monospace;
font-size: 38px;
font-weight: 800;
color: #F97316;
}
.icon-5d .ending {
font-family: 'JetBrains Mono', monospace;
font-size: 38px;
font-weight: 800;
color: #22D3EE;
}
.icon-5d .cursor {
width: 3px;
height: 40px;
background: rgba(255,255,255,0.6);
margin-left: 2px;
animation: blink 1.2s step-end infinite;
align-self: center;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ═══════════════════════════════════════
5E: Layered depth — multiple verb forms
stacked with depth, formula on top
═══════════════════════════════════════ */
.icon-5e {
background: #0C0A09;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.icon-5e::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 50% 70%, rgba(249,115,22,0.06) 0%, transparent 60%);
}
.icon-5e .bg-forms {
position: absolute;
z-index: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
opacity: 0.12;
}
.icon-5e .bg-forms span {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: white;
}
.icon-5e .main-formula {
display: flex;
align-items: baseline;
gap: 4px;
z-index: 1;
margin-bottom: 6px;
}
.icon-5e .main-formula .stem {
font-family: 'JetBrains Mono', monospace;
font-size: 26px;
font-weight: 700;
color: #F97316;
}
.icon-5e .main-formula .op {
font-family: 'JetBrains Mono', monospace;
font-size: 18px;
color: #44403c;
}
.icon-5e .main-formula .ending {
font-family: 'JetBrains Mono', monospace;
font-size: 26px;
font-weight: 700;
color: #22D3EE;
}
.icon-5e .result {
font-family: 'Playfair Display', serif;
font-size: 46px;
font-weight: 900;
color: white;
z-index: 1;
letter-spacing: -1px;
}
.icon-5e .other-forms {
display: flex;
gap: 8px;
margin-top: 10px;
z-index: 1;
}
.icon-5e .other-forms span {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: #57534e;
}
.icon-5e .other-forms .active {
color: #FB923C;
}
</style>
</head>
<body>
<h1>Icon #5 — Variations</h1>
<p class="subtitle">Neon Grammar refined five ways</p>
<div class="grid">
<!-- 5A: Original refined -->
<div class="option">
<div class="icon-wrap icon-5a">
<div class="formula">
<span class="stem">habl</span>
<span class="op">+</span>
<span class="ending">o</span>
</div>
<div class="result">hablo</div>
<div class="glow"></div>
</div>
<div class="label">
<h3>5A. Clean Formula</h3>
<p>Tighter spacing, no brand text. Formula + result, amber glow underneath.</p>
</div>
</div>
<!-- 5B: Ember glow -->
<div class="option">
<div class="icon-wrap icon-5b">
<div class="formula">
<span class="stem">habl</span>
<span class="op">+</span>
<span class="ending">o</span>
</div>
<div class="result">hablo</div>
<div class="brand">conjuga</div>
</div>
<div class="label">
<h3>5B. Ember Glow</h3>
<p>Warm radial glow behind the result. Formula faded, word glows. Brand text below.</p>
</div>
</div>
<!-- 5C: Split color -->
<div class="option">
<div class="icon-wrap icon-5c">
<div class="top-line"></div>
<div class="result"><span class="s">habl</span><span class="e">o</span></div>
<div class="formula">
<span class="tag-s">stem</span>
<span class="tag-plus">+</span>
<span class="tag-e">ending</span>
</div>
</div>
<div class="label">
<h3>5C. Split Color</h3>
<p>The word itself is two-toned — stem in orange, ending in cyan. Gradient borders top and bottom.</p>
</div>
</div>
<!-- 5D: Minimal mono -->
<div class="option">
<div class="icon-wrap icon-5d">
<div class="content">
<span class="stem">habl</span><span class="ending">o</span>
<div class="cursor"></div>
</div>
</div>
<div class="label">
<h3>5D. Terminal</h3>
<p>Pure black, just the split-colored word with a blinking cursor. Stark, developer-feel.</p>
</div>
</div>
<!-- 5E: Layered depth -->
<div class="option">
<div class="icon-wrap icon-5e">
<div class="bg-forms">
<span>hablamos</span>
<span>habláis</span>
<span>hablan</span>
<span>hablas</span>
<span>habla</span>
</div>
<div class="main-formula">
<span class="stem">habl</span>
<span class="op">+</span>
<span class="ending">o</span>
</div>
<div class="result">hablo</div>
<div class="other-forms">
<span class="active">yo</span>
<span></span>
<span>él</span>
<span>nos</span>
<span>ellos</span>
</div>
</div>
<div class="label">
<h3>5E. Layered Depth</h3>
<p>Ghost conjugations in background, formula + result in front. Shows the full table subtly.</p>
</div>
</div>
</div>
</body>
</html>

438
Conjuga/AppIcons/icons.html Normal file
View File

@@ -0,0 +1,438 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conjuga — App Icon Options</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Space+Mono:wght@700&family=Instrument+Serif&family=Playfair+Display:wght@900&family=Syne:wght@800&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
min-height: 100vh;
}
h1 {
font-family: 'Instrument Serif', serif;
font-size: 3rem;
margin-bottom: 8px;
letter-spacing: -1px;
}
.subtitle {
color: #666;
font-size: 1rem;
margin-bottom: 60px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 48px;
max-width: 1400px;
width: 100%;
}
.option {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.icon-wrap {
width: 200px;
height: 200px;
border-radius: 44px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05);
position: relative;
}
.label {
text-align: center;
}
.label h3 {
font-family: 'DM Serif Display', serif;
font-size: 1.3rem;
margin-bottom: 4px;
}
.label p {
color: #888;
font-size: 0.85rem;
max-width: 240px;
}
/* ═══════════════════════════════════════
ICON 1: "Warm Serif" — Editorial elegance
Rich amber gradient, classic serif C
═══════════════════════════════════════ */
.icon-1 {
background: linear-gradient(145deg, #F97316, #EA580C, #C2410C);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.icon-1::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.25) 0%, transparent 50%);
}
.icon-1 .letter {
font-family: 'Playfair Display', serif;
font-size: 140px;
font-weight: 900;
color: white;
text-shadow: 0 4px 20px rgba(0,0,0,0.2);
line-height: 1;
position: relative;
z-index: 1;
}
.icon-1 .accent {
position: absolute;
bottom: 28px;
right: 30px;
font-family: 'Space Mono', monospace;
font-size: 16px;
color: rgba(255,255,255,0.7);
letter-spacing: 1px;
z-index: 1;
}
/* ═══════════════════════════════════════
ICON 2: "Dark Scholar" — Deep navy, gold accent
Sophisticated, knowledge-focused
═══════════════════════════════════════ */
.icon-2 {
background: linear-gradient(160deg, #1e293b, #0f172a, #020617);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.icon-2::before {
content: '';
position: absolute;
top: -40px;
right: -40px;
width: 120px;
height: 120px;
border-radius: 50%;
background: radial-gradient(circle, rgba(251,191,36,0.3) 0%, transparent 70%);
}
.icon-2 .main {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
z-index: 1;
}
.icon-2 .verb-text {
font-family: 'Instrument Serif', serif;
font-size: 42px;
color: #FBBF24;
letter-spacing: 2px;
}
.icon-2 .endings {
display: flex;
gap: 6px;
}
.icon-2 .ending {
font-family: 'Space Mono', monospace;
font-size: 11px;
color: rgba(148,163,184,0.8);
padding: 3px 7px;
border: 1px solid rgba(148,163,184,0.2);
border-radius: 4px;
}
.icon-2 .line {
width: 60px;
height: 1px;
background: linear-gradient(90deg, transparent, #FBBF24, transparent);
margin: 6px 0;
}
.icon-2 .persons {
font-family: 'Space Mono', monospace;
font-size: 9px;
color: rgba(148,163,184,0.4);
letter-spacing: 3px;
}
/* ═══════════════════════════════════════
ICON 3: "Vibrant Stack" — Stacked verb forms
Energetic, modern, color-coded
═══════════════════════════════════════ */
.icon-3 {
background: #FAFAF9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
position: relative;
}
.icon-3 .row {
display: flex;
align-items: center;
gap: 4px;
font-family: 'Syne', sans-serif;
font-size: 14px;
font-weight: 800;
}
.icon-3 .person {
width: 52px;
text-align: right;
color: #a8a29e;
font-size: 11px;
font-weight: 400;
font-family: system-ui;
}
.icon-3 .form {
padding: 4px 10px;
border-radius: 6px;
color: white;
font-size: 13px;
min-width: 80px;
text-align: center;
}
.icon-3 .f-orange { background: #F97316; }
.icon-3 .f-blue { background: #3B82F6; }
.icon-3 .f-green { background: #10B981; }
.icon-3 .f-purple { background: #8B5CF6; }
.icon-3 .f-rose { background: #F43F5E; }
.icon-3 .f-amber { background: #F59E0B; }
.icon-3 .title-badge {
font-family: 'Syne', sans-serif;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 3px;
color: #57534e;
margin-bottom: 4px;
}
/* ═══════════════════════════════════════
ICON 4: "Brushstroke" — Organic, painterly
Spanish-inspired warmth
═══════════════════════════════════════ */
.icon-4 {
background: linear-gradient(155deg, #FEF3C7, #FDE68A, #FCD34D);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.icon-4::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 20% 80%, rgba(234,88,12,0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(220,38,38,0.1) 0%, transparent 50%);
}
.icon-4 .c-letter {
font-family: 'Instrument Serif', serif;
font-size: 120px;
color: #92400E;
position: relative;
z-index: 1;
line-height: 1;
}
.icon-4 .dot {
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
z-index: 2;
}
.icon-4 .dot-1 { background: #DC2626; top: 30px; right: 40px; }
.icon-4 .dot-2 { background: #EA580C; bottom: 50px; left: 35px; width: 10px; height: 10px; }
.icon-4 .dot-3 { background: #92400E; top: 55px; left: 45px; width: 8px; height: 8px; }
.icon-4 .tilde {
position: absolute;
bottom: 30px;
right: 28px;
font-family: 'Instrument Serif', serif;
font-size: 36px;
color: #B45309;
z-index: 2;
opacity: 0.6;
}
/* ═══════════════════════════════════════
ICON 5: "Neon Grammar" — Bold, techy, striking
Dark with electric accents
═══════════════════════════════════════ */
.icon-5 {
background: #0C0A09;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.icon-5::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 50% 50%, rgba(249,115,22,0.08) 0%, transparent 60%);
}
.icon-5 .formula {
display: flex;
align-items: center;
gap: 6px;
z-index: 1;
margin-bottom: 12px;
}
.icon-5 .stem {
font-family: 'Space Mono', monospace;
font-size: 28px;
font-weight: 700;
color: #F97316;
}
.icon-5 .op {
font-family: 'Space Mono', monospace;
font-size: 20px;
color: #57534e;
}
.icon-5 .ending {
font-family: 'Space Mono', monospace;
font-size: 28px;
font-weight: 700;
color: #22D3EE;
}
.icon-5 .result {
font-family: 'Playfair Display', serif;
font-size: 44px;
font-weight: 900;
color: white;
z-index: 1;
letter-spacing: -1px;
}
.icon-5 .glow-line {
width: 100px;
height: 2px;
background: linear-gradient(90deg, transparent, #F97316, transparent);
margin-top: 10px;
z-index: 1;
}
.icon-5 .brand {
font-family: 'Space Mono', monospace;
font-size: 8px;
color: #57534e;
letter-spacing: 4px;
text-transform: uppercase;
margin-top: 8px;
z-index: 1;
}
</style>
</head>
<body>
<h1>Conjuga</h1>
<p class="subtitle">Choose your app icon</p>
<div class="grid">
<!-- ICON 1: Warm Serif -->
<div class="option">
<div class="icon-wrap icon-1">
<span class="letter">C</span>
<span class="accent">—ar —er —ir</span>
</div>
<div class="label">
<h3>1. Warm Serif</h3>
<p>Bold serif C on a warm amber gradient. Classic, confident, editorial.</p>
</div>
</div>
<!-- ICON 2: Dark Scholar -->
<div class="option">
<div class="icon-wrap icon-2">
<div class="main">
<div class="verb-text">hablar</div>
<div class="line"></div>
<div class="endings">
<span class="ending">-o</span>
<span class="ending">-as</span>
<span class="ending">-a</span>
<span class="ending">-amos</span>
</div>
<div class="persons">yo · tú · él · nos</div>
</div>
</div>
<div class="label">
<h3>2. Dark Scholar</h3>
<p>Deep navy with gold verb and endings. Sophisticated, knowledge-focused.</p>
</div>
</div>
<!-- ICON 3: Vibrant Stack -->
<div class="option">
<div class="icon-wrap icon-3">
<div class="title-badge">Conjuga</div>
<div class="row"><span class="person">yo</span><span class="form f-orange">hablo</span></div>
<div class="row"><span class="person"></span><span class="form f-blue">hablas</span></div>
<div class="row"><span class="person">él</span><span class="form f-green">habla</span></div>
<div class="row"><span class="person">nos.</span><span class="form f-purple">hablamos</span></div>
<div class="row"><span class="person">vos.</span><span class="form f-rose">habláis</span></div>
<div class="row"><span class="person">ellos</span><span class="form f-amber">hablan</span></div>
</div>
<div class="label">
<h3>3. Vibrant Stack</h3>
<p>Color-coded conjugation table on white. Energetic, educational, modern.</p>
</div>
</div>
<!-- ICON 4: Brushstroke -->
<div class="option">
<div class="icon-wrap icon-4">
<span class="c-letter">C</span>
<div class="dot dot-1"></div>
<div class="dot dot-2"></div>
<div class="dot dot-3"></div>
<span class="tilde">ñ</span>
</div>
<div class="label">
<h3>4. Sunlit Brush</h3>
<p>Warm yellow with serif C and Spanish accents. Organic, inviting, cultural.</p>
</div>
</div>
<!-- ICON 5: Neon Grammar -->
<div class="option">
<div class="icon-wrap icon-5">
<div class="formula">
<span class="stem">habl</span>
<span class="op">+</span>
<span class="ending">o</span>
</div>
<div class="result">hablo</div>
<div class="glow-line"></div>
<div class="brand">conjuga</div>
</div>
<div class="label">
<h3>5. Neon Grammar</h3>
<p>Dark with stem+ending formula. Technical, striking, shows how conjugation works.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@700;800&family=Playfair+Display:wght@900&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; }
body {
width: 1024px;
height: 1024px;
overflow: hidden;
background: #0C0A09;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 45%, rgba(249,115,22,0.10) 0%, transparent 55%);
}
.formula {
display: flex;
align-items: baseline;
gap: 12px;
z-index: 1;
margin-bottom: 20px;
}
.stem {
font-family: 'JetBrains Mono', monospace;
font-size: 100px;
font-weight: 700;
color: #F97316;
}
.op {
font-family: 'JetBrains Mono', monospace;
font-size: 72px;
color: #44403c;
}
.ending {
font-family: 'JetBrains Mono', monospace;
font-size: 100px;
font-weight: 700;
color: #22D3EE;
}
.result {
font-family: 'Playfair Display', serif;
font-size: 220px;
font-weight: 900;
color: white;
z-index: 1;
letter-spacing: -8px;
line-height: 1;
}
.glow {
width: 340px;
height: 6px;
background: linear-gradient(90deg, transparent, #F97316, transparent);
margin-top: 24px;
z-index: 1;
border-radius: 3px;
}
</style>
</head>
<body>
<div class="formula">
<span class="stem">habl</span>
<span class="op">+</span>
<span class="ending">o</span>
</div>
<div class="result">hablo</div>
<div class="glow"></div>
</body>
</html>

View File

@@ -0,0 +1,839 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 63;
objects = {
/* Begin PBXBuildFile section */
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; };
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */; };
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; };
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
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 */; };
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 */; };
62037CE76C9915230CE7DD2D /* Verb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165B15630F4560F5891D9763 /* Verb.swift */; };
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
7004FF1EE74DBD15853CFE5C /* VerbForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6C1705F97FA0D59E996529 /* VerbForm.swift */; };
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
8324BD7600EF7E33941EF327 /* IrregularSpan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21FB1479EA5779A109BC517D /* IrregularSpan.swift */; };
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
8FA34F10023A38DA67EE48F5 /* TenseGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */; };
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
A11A11111111111111111111 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11B11111111111111111111 /* ReviewStore.swift */; };
A22A22222222222222222222 /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22B22222222222222222222 /* ReferenceStore.swift */; };
A33A33333333333333333333 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33B33333333333333333333 /* CourseReviewStore.swift */; };
A44A44444444444444444444 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44B44444444444444444444 /* PracticeSessionService.swift */; };
A55A55555555555555555555 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55B55555555555555555555 /* StartupCoordinator.swift */; };
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.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 */; };
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
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 */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
642028B6CDF96AC823DD42DD /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AB7396D9C3E14B65B5238368 /* Project object */;
proxyType = 1;
remoteGlobalIDString = F73909B4044081DB8F6272AF;
remoteInfo = ConjugaWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
564DF4E0E67EB112CC99B25B /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; };
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; };
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
165B15630F4560F5891D9763 /* Verb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Verb.swift; sourceTree = "<group>"; };
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; };
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; };
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
21FB1479EA5779A109BC517D /* IrregularSpan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularSpan.swift; sourceTree = "<group>"; };
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; };
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
8B6C1705F97FA0D59E996529 /* VerbForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbForm.swift; sourceTree = "<group>"; };
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
B11B11111111111111111111 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
B22B22222222222222222222 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
B33B33333333333333333333 /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
B44B44444444444444444444 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
B55B55555555555555555555 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = SharedModels; sourceTree = SOURCE_ROOT; };
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseGuide.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
0116A6DE63FDF7EAC771CE28 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
218E982FC4267949F82AABAD /* SharedModels in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
2882B19DCEE63C5FB6421C75 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
00DB828631287E203DF85F58 /* Conjuga */ = {
isa = PBXGroup;
children = (
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */,
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */,
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
BC273716CD14A99EFF8206CA /* course_data.json */,
7E6AF62A3A949630E067DC22 /* Info.plist */,
353C5DE41FD410FA82E3AED7 /* Models */,
1994867BC8E985795A172854 /* Services */,
3C75490F53C34A37084FF478 /* ViewModels */,
A81CA75762B08D35D5B7A44D /* Views */,
);
path = Conjuga;
sourceTree = "<group>";
};
0931AEB5B728C3A03F06A1CA /* Settings */ = {
isa = PBXGroup;
children = (
BCCC95A95581458E068E0484 /* SettingsView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
180FE0BE39C06AF578F2B9A2 /* Verbs */ = {
isa = PBXGroup;
children = (
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */,
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */,
);
path = Verbs;
sourceTree = "<group>";
};
1994867BC8E985795A172854 /* Services */ = {
isa = PBXGroup;
children = (
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
B33B33333333333333333333 /* CourseReviewStore.swift */,
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
B44B44444444444444444444 /* PracticeSessionService.swift */,
B22B22222222222222222222 /* ReferenceStore.swift */,
B11B11111111111111111111 /* ReviewStore.swift */,
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
B55B55555555555555555555 /* StartupCoordinator.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
);
path = Services;
sourceTree = "<group>";
};
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
isa = PBXGroup;
children = (
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */,
);
path = Onboarding;
sourceTree = "<group>";
};
353C5DE41FD410FA82E3AED7 /* Models */ = {
isa = PBXGroup;
children = (
0313D24F96E6A0039C34341F /* DailyLog.swift */,
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
21FB1479EA5779A109BC517D /* IrregularSpan.swift */,
626873572466403C0288090D /* QuizType.swift */,
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */,
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */,
165B15630F4560F5891D9763 /* Verb.swift */,
8B6C1705F97FA0D59E996529 /* VerbForm.swift */,
);
path = Models;
sourceTree = "<group>";
};
35D7EC545D2840269F3A105A /* Components */ = {
isa = PBXGroup;
children = (
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */,
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */,
80D974250C396589656B8443 /* HandwritingCanvas.swift */,
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */,
102F0E136CDFF8CED710210F /* TensePill.swift */,
);
path = Components;
sourceTree = "<group>";
};
3C75490F53C34A37084FF478 /* ViewModels */ = {
isa = PBXGroup;
children = (
C359C051FB157EF447561405 /* PracticeViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = {
isa = PBXGroup;
children = (
43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */,
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */,
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */,
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */,
AC34396050805693AA4AC582 /* Info.plist */,
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */,
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */,
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */,
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */,
);
path = ConjugaWidget;
sourceTree = "<group>";
};
5A23E5D4EFE8E46030CA9D77 /* Practice */ = {
isa = PBXGroup;
children = (
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */,
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
10C16AA6022E4742898745CE /* TypingView.swift */,
);
path = Practice;
sourceTree = "<group>";
};
8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
isa = PBXGroup;
children = (
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
);
path = Guide;
sourceTree = "<group>";
};
A591A3B6F1F13D23D68D7A9D = {
isa = PBXGroup;
children = (
00DB828631287E203DF85F58 /* Conjuga */,
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */,
F7D740BB7D1E23949D4C1AE5 /* Packages */,
F605D24E5EA11065FD18AF7E /* Products */,
);
sourceTree = "<group>";
};
A81CA75762B08D35D5B7A44D /* Views */ = {
isa = PBXGroup;
children = (
5983A534E4836F30B5281ACB /* MainTabView.swift */,
35D7EC545D2840269F3A105A /* Components */,
BE5A40BAC9DD6884C58A2096 /* Course */,
BA34B77A38B698101DBBE241 /* Dashboard */,
8102F7FA5BFE6D38B2212AD3 /* Guide */,
29F9EEAED5A6969FEDEAE227 /* Onboarding */,
5A23E5D4EFE8E46030CA9D77 /* Practice */,
0931AEB5B728C3A03F06A1CA /* Settings */,
180FE0BE39C06AF578F2B9A2 /* Verbs */,
);
path = Views;
sourceTree = "<group>";
};
BA34B77A38B698101DBBE241 /* Dashboard */ = {
isa = PBXGroup;
children = (
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */,
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */,
);
path = Dashboard;
sourceTree = "<group>";
};
BE5A40BAC9DD6884C58A2096 /* Course */ = {
isa = PBXGroup;
children = (
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
833516C5D57F164C8660A479 /* CourseView.swift */,
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
);
path = Course;
sourceTree = "<group>";
};
F605D24E5EA11065FD18AF7E /* Products */ = {
isa = PBXGroup;
children = (
16C1F74196C3C5628953BE3F /* Conjuga.app */,
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
F7D740BB7D1E23949D4C1AE5 /* Packages */ = {
isa = PBXGroup;
children = (
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */,
);
name = Packages;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
96127FACA68AE541F5C0F8BC /* Conjuga */ = {
isa = PBXNativeTarget;
buildConfigurationList = 6D3865D764BE65B4E10E971C /* Build configuration list for PBXNativeTarget "Conjuga" */;
buildPhases = (
1180AD08C008F8DB0CDCC944 /* Sources */,
B74A8384221C70A670B902D8 /* Resources */,
2882B19DCEE63C5FB6421C75 /* Frameworks */,
564DF4E0E67EB112CC99B25B /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
0B370CF10B68E386093E5BB2 /* PBXTargetDependency */,
);
name = Conjuga;
packageProductDependencies = (
BCCBABD74CADDB118179D8E9 /* SharedModels */,
);
productName = Conjuga;
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
productType = "com.apple.product-type.application";
};
F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA7E12CF28EB750C2B8BB2F1 /* Build configuration list for PBXNativeTarget "ConjugaWidgetExtension" */;
buildPhases = (
217A29BCEDD9D44B6DD85AF6 /* Sources */,
0116A6DE63FDF7EAC771CE28 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = ConjugaWidgetExtension;
packageProductDependencies = (
4A4D7B02884EBA9ACD93F0FD /* SharedModels */,
);
productName = ConjugaWidgetExtension;
productReference = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AB7396D9C3E14B65B5238368 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2600;
TargetAttributes = {
96127FACA68AE541F5C0F8BC = {
DevelopmentTeam = V3PF3M6B6U;
ProvisioningStyle = Automatic;
};
F73909B4044081DB8F6272AF = {
DevelopmentTeam = V3PF3M6B6U;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = F011A5DA3101F6F7CA7D2D95 /* Build configuration list for PBXProject "Conjuga" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = A591A3B6F1F13D23D68D7A9D;
minimizedProjectReferenceProxies = 1;
packageReferences = (
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
);
projectDirPath = "";
projectRoot = "";
targets = (
96127FACA68AE541F5C0F8BC /* Conjuga */,
F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
B74A8384221C70A670B902D8 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1180AD08C008F8DB0CDCC944 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */,
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */,
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */,
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
8324BD7600EF7E33941EF327 /* IrregularSpan.swift in Sources */,
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
A44A44444444444444444444 /* PracticeSessionService.swift in Sources */,
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
A22A22222222222222222222 /* ReferenceStore.swift in Sources */,
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
A11A11111111111111111111 /* ReviewStore.swift in Sources */,
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */,
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
A55A55555555555555555555 /* StartupCoordinator.swift in Sources */,
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
8FA34F10023A38DA67EE48F5 /* TenseGuide.swift in Sources */,
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
62037CE76C9915230CE7DD2D /* Verb.swift in Sources */,
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
7004FF1EE74DBD15853CFE5C /* VerbForm.swift in Sources */,
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
A33A33333333333333333333 /* CourseReviewStore.swift in Sources */,
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
217A29BCEDD9D44B6DD85AF6 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */,
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */,
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */,
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */,
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */,
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */,
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
0B370CF10B68E386093E5BB2 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */;
targetProxy = 642028B6CDF96AC823DD42DD /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
1FF8E3681DEF15C97149EF8A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 6.0;
};
name = Release;
};
30F9C426EB50A8833B5F08C6 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = ConjugaWidget/ConjugaWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_DEBUG_DYLIB = NO;
INFOPLIST_FILE = ConjugaWidget/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.conjuga.app.widget;
SDKROOT = iphoneos;
SWIFT_STRICT_CONCURRENCY = complete;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
6E8CD3A87B2AD9F2BA123386 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Conjuga/Conjuga.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_DEBUG_DYLIB = NO;
INFOPLIST_FILE = Conjuga/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.conjuga.app;
SDKROOT = iphoneos;
SWIFT_STRICT_CONCURRENCY = complete;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
8FCEEF7252C3BF32AE11488A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = ConjugaWidget/ConjugaWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_DEBUG_DYLIB = NO;
INFOPLIST_FILE = ConjugaWidget/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.conjuga.app.widget;
SDKROOT = iphoneos;
SWIFT_STRICT_CONCURRENCY = complete;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97D5F61C790E5700F45F7125 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Conjuga/Conjuga.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_DEBUG_DYLIB = NO;
INFOPLIST_FILE = Conjuga/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.conjuga.app;
SDKROOT = iphoneos;
SWIFT_STRICT_CONCURRENCY = complete;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
B9223DC55BB69E9AB81B59AE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 6.0;
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
6D3865D764BE65B4E10E971C /* Build configuration list for PBXNativeTarget "Conjuga" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6E8CD3A87B2AD9F2BA123386 /* Debug */,
97D5F61C790E5700F45F7125 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
EA7E12CF28EB750C2B8BB2F1 /* Build configuration list for PBXNativeTarget "ConjugaWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
8FCEEF7252C3BF32AE11488A /* Debug */,
30F9C426EB50A8833B5F08C6 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
F011A5DA3101F6F7CA7D2D95 /* Build configuration list for PBXProject "Conjuga" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B9223DC55BB69E9AB81B59AE /* Debug */,
1FF8E3681DEF15C97149EF8A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = SharedModels;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
isa = XCSwiftPackageProductDependency;
productName = SharedModels;
};
BCCBABD74CADDB118179D8E9 /* SharedModels */ = {
isa = XCSwiftPackageProductDependency;
productName = SharedModels;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = AB7396D9C3E14B65B5238368 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.conjuga.app</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.conjuga.app</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudDocuments</string>
<string>CloudKit</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,77 @@
import SwiftUI
import SharedModels
import SwiftData
import WidgetKit
@main
struct ConjugaApp: App {
@AppStorage("onboardingComplete") private var onboardingComplete = false
@Environment(\.scenePhase) private var scenePhase
@State private var isReady = false
let container: ModelContainer
init() {
do {
let localConfig = ModelConfiguration(
"local",
schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
groupContainer: .none,
cloudKitDatabase: .none
)
let cloudConfig = ModelConfiguration(
"cloud",
schema: Schema([
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self,
]),
cloudKitDatabase: .private("iCloud.com.conjuga.app")
)
container = try ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self,
configurations: localConfig, cloudConfig
)
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
Group {
if !isReady {
VStack(spacing: 16) {
ProgressView()
.controlSize(.large)
Text("Preparing data...")
.foregroundStyle(.secondary)
}
} else if onboardingComplete {
MainTabView()
} else {
OnboardingView()
}
}
.task {
await StartupCoordinator.run(container: container)
WidgetDataService.update(context: container.mainContext)
isReady = true
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
let context = container.mainContext
WidgetDataService.update(context: context)
}
}
}
.modelContainer(container)
}
}

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Conjuga</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.education</string>
<key>UILaunchScreen</key>
<dict/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,49 @@
import SwiftData
import Foundation
@Model
final class DailyLog {
var id: String = ""
var dateString: String = ""
var reviewCount: Int = 0
var correctCount: Int = 0
var accuracy: Double {
guard reviewCount > 0 else { return 0 }
return Double(correctCount) / Double(reviewCount)
}
init(dateString: String, reviewCount: Int = 0, correctCount: Int = 0) {
self.id = Self.makeID(dateString)
self.dateString = dateString
self.reviewCount = reviewCount
self.correctCount = correctCount
}
static func makeID(_ dateString: String) -> String {
dateString
}
func refreshIdentityIfNeeded() {
let expected = Self.makeID(dateString)
if id != expected {
id = expected
}
}
static func dateString(from date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
static func todayString() -> String {
dateString(from: Date())
}
static func date(from string: String) -> Date? {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: string)
}
}

View File

@@ -0,0 +1,889 @@
import Foundation
struct GrammarNote: Identifiable {
let id: String
let title: String
let category: String
let body: String
static let allNotes: [GrammarNote] = [
serVsEstar,
porVsPara,
preteriteVsImperfect,
subjunctiveTriggers,
reflexiveVerbs,
objectPronouns,
gustarLikeVerbs,
comparativesSuperlatives,
conditionalIfClauses,
commandsImperative,
saberVsConocer,
doubleNegatives,
adjectivePlacement,
tenerExpressions,
personalA,
relativePronouns,
futureVsIrA,
accentMarksStress,
seConstructions,
estarGerundProgressive,
]
// MARK: - 1. Ser vs Estar
private static let serVsEstar = GrammarNote(
id: "ser-vs-estar",
title: "Ser vs Estar",
category: "Core Concepts",
body: """
Spanish has two verbs meaning "to be": *ser* and *estar*. Choosing the wrong one changes the meaning of a sentence entirely.
**Ser** is used for permanent or inherent qualities — identity, origin, profession, time, physical descriptions that don't change, and personality traits. Think of ser as describing *what* something is.
*Ella es doctora.* — She is a doctor.
*Nosotros somos de México.* — We are from Mexico.
*Son las tres de la tarde.* — It is three in the afternoon.
*Mi hermano es alto y simpático.* — My brother is tall and friendly.
*La fiesta es en mi casa.* — The party is at my house.
**Estar** is used for temporary states, locations, emotions, conditions, and the results of actions. Think of estar as describing *how* something is right now.
*Estoy cansado hoy.* — I am tired today.
*El libro está en la mesa.* — The book is on the table.
*Ella está feliz porque aprobó el examen.* — She is happy because she passed the exam.
*La sopa está caliente.* — The soup is hot.
*Estamos estudiando español.* — We are studying Spanish.
**Key trick:** Some adjectives change meaning depending on which verb you use.
*Él es aburrido.* — He is boring (personality trait).
*Él está aburrido.* — He is bored (temporary feeling).
*Ella es lista.* — She is clever.
*Ella está lista.* — She is ready.
*La manzana es verde.* — The apple is green (its natural color).
*La manzana está verde.* — The apple is unripe.
**Memory aid:** Use the acronym DOCTOR for ser (Description, Occupation, Characteristic, Time, Origin, Relationship) and PLACE for estar (Position, Location, Action in progress, Condition, Emotion).
"""
)
// MARK: - 2. Por vs Para
private static let porVsPara = GrammarNote(
id: "por-vs-para",
title: "Por vs Para",
category: "Core Concepts",
body: """
Both *por* and *para* translate to "for" in English, but they are not interchangeable. Each has distinct uses.
**Para** indicates purpose, destination, recipient, deadline, or goal — it points forward toward something.
*Este regalo es para ti.* — This gift is for you (recipient).
*Salimos para Madrid mañana.* — We leave for Madrid tomorrow (destination).
*Estudio para aprender.* — I study in order to learn (purpose).
*Necesito el informe para el lunes.* — I need the report by Monday (deadline).
*Para ser estudiante, habla muy bien.* — For a student, she speaks very well (comparison).
**Por** indicates cause, exchange, duration, movement through, or means — it looks back at the reason or path behind something.
*Gracias por tu ayuda.* — Thanks for your help (cause/reason).
*Pagué veinte dólares por el libro.* — I paid twenty dollars for the book (exchange).
*Caminamos por el parque.* — We walked through the park (movement through).
*Estudié por dos horas.* — I studied for two hours (duration).
*Te llamo por teléfono.* — I'll call you by phone (means).
*La carta fue escrita por María.* — The letter was written by María (agent in passive voice).
**Common fixed expressions with por:** *por favor* (please), *por supuesto* (of course), *por ejemplo* (for example), *por eso* (that's why), *por fin* (finally), *por lo menos* (at least).
**Common fixed expressions with para:** *para siempre* (forever), *para colmo* (to top it off), *para nada* (not at all).
**Memory aid:** Para points to a destination or purpose (forward-looking). Por explains the cause or path (backward-looking). Ask yourself: "Am I explaining *why* (por) or *what for* (para)?"
"""
)
// MARK: - 3. Preterite vs Imperfect
private static let preteriteVsImperfect = GrammarNote(
id: "preterite-vs-imperfect",
title: "Preterite vs Imperfect",
category: "Past Tenses",
body: """
Spanish uses two simple past tenses: the preterite (*pretérito indefinido*) for completed actions and the imperfect (*pretérito imperfecto*) for ongoing or habitual past actions.
**Preterite** describes actions that started and ended at a specific point in the past — they are completed, one-time, or sequential events.
*Ayer comí una pizza.* — Yesterday I ate a pizza.
*Ella llegó a las ocho.* — She arrived at eight.
*Ellos vivieron en París por dos años.* — They lived in Paris for two years (and then stopped).
*Me levanté, me duché y salí de casa.* — I got up, showered, and left the house.
**Imperfect** describes ongoing states, habitual actions, or background descriptions in the past — there is no defined endpoint.
*Cuando era niño, jugaba en el parque.* — When I was a child, I used to play in the park.
*Hacía sol y los pájaros cantaban.* — It was sunny and the birds were singing.
*Siempre comíamos juntos los domingos.* — We always ate together on Sundays.
*Ella tenía diez años cuando se mudó.* — She was ten years old when she moved.
**The movie rule:** Think of a story as a movie. The imperfect sets the scene (background, descriptions, weather, feelings), while the preterite advances the plot (specific events that happen).
*Llovía mucho (imperfect — background) cuando de repente sonó el teléfono (preterite — plot).* — It was raining a lot when suddenly the phone rang.
*Yo dormía (imperfect — background) cuando alguien tocó la puerta (preterite — plot).* — I was sleeping when someone knocked on the door.
**Key time markers:** Preterite: *ayer, anoche, una vez, de repente, el año pasado*. Imperfect: *siempre, todos los días, mientras, generalmente, de niño*.
**Meaning changes:** Some verbs shift meaning between tenses. *Conocí a Juan* (I met Juan — first time). *Conocía a Juan* (I knew Juan — ongoing familiarity). *Supe la verdad* (I found out the truth). *Sabía la verdad* (I knew the truth).
"""
)
// MARK: - 4. Subjunctive Triggers
private static let subjunctiveTriggers = GrammarNote(
id: "subjunctive-triggers",
title: "Subjunctive Triggers",
category: "Subjunctive",
body: """
The subjunctive mood (*subjuntivo*) expresses wishes, doubts, emotions, and hypothetical situations. Use the acronym **WEIRDO** to remember the categories that trigger it.
**W — Wishes and desires:** Verbs like *querer, desear, esperar, preferir*.
*Quiero que vengas a la fiesta.* — I want you to come to the party.
*Esperamos que todo salga bien.* — We hope everything goes well.
**E — Emotions:** Verbs like *alegrarse de, temer, sentir, sorprender*.
*Me alegra que estés aquí.* — I'm glad you are here.
*Siento que no puedas venir.* — I'm sorry you can't come.
**I — Impersonal expressions:** *Es importante que, es necesario que, es posible que*.
*Es necesario que estudies más.* — It's necessary that you study more.
*Es posible que llueva mañana.* — It's possible that it rains tomorrow.
**R — Recommendations and requests:** Verbs like *recomendar, sugerir, pedir, aconsejar, exigir*.
*Te recomiendo que pruebes la paella.* — I recommend that you try the paella.
*El profesor pide que hagamos la tarea.* — The teacher asks that we do the homework.
**D — Doubt and denial:** Verbs like *dudar, negar, no creer, no pensar*.
*Dudo que él sepa la respuesta.* — I doubt he knows the answer.
*No creo que sea verdad.* — I don't think it's true.
**O — Ojalá:** This word (from Arabic, meaning "God willing") always triggers the subjunctive.
*Ojalá que haga buen tiempo.* — I hope the weather is nice.
*Ojalá pudiera viajar más.* — I wish I could travel more.
**Important rule:** The subjunctive requires a change of subject. If the subject is the same, use the infinitive instead.
*Quiero ir* (same subject — I want to go). *Quiero que tú vayas* (different subjects — I want you to go).
**Common trigger phrases:** *antes de que* (before), *para que* (so that), *a menos que* (unless), *sin que* (without), *cuando* (when — future), *hasta que* (until).
"""
)
// MARK: - 5. Reflexive Verbs
private static let reflexiveVerbs = GrammarNote(
id: "reflexive-verbs",
title: "Reflexive Verbs",
category: "Verb Types",
body: """
Reflexive verbs (*verbos reflexivos*) indicate that the subject performs and receives the action — the action "reflects" back. They are identified by the pronoun *se* attached to the infinitive: *lavarse, vestirse, despertarse*.
**Reflexive pronouns** match the subject: *me* (yo), *te* (tú), *se* (él/ella/usted), *nos* (nosotros), *os* (vosotros), *se* (ellos/ustedes).
*Me despierto a las siete.* — I wake (myself) up at seven.
*Te duchas todas las mañanas.* — You shower every morning.
*Ella se viste rápidamente.* — She gets dressed quickly.
*Nos acostamos tarde los sábados.* — We go to bed late on Saturdays.
**Common reflexive verbs:** *levantarse* (to get up), *acostarse* (to go to bed), *sentarse* (to sit down), *llamarse* (to be called), *sentirse* (to feel), *preocuparse* (to worry), *quejarse* (to complain), *irse* (to leave), *ponerse* (to put on / to become), *divertirse* (to have fun).
**Pronoun placement:** The pronoun goes before a conjugated verb, but attaches to the end of infinitives, gerunds, and affirmative commands.
*Me lavo las manos.* — I wash my hands.
*Voy a lavarme las manos.* — I'm going to wash my hands.
*Estoy lavándome las manos.* — I'm washing my hands.
*¡Lávate las manos!* — Wash your hands! (command)
**Reflexive vs non-reflexive meaning changes:** Some verbs change meaning when used reflexively.
*Dormir* — to sleep. *Dormirse* — to fall asleep.
*Ir* — to go. *Irse* — to leave / go away.
*Poner* — to put. *Ponerse* — to put on (clothing) / to become.
*Llamar* — to call. *Llamarse* — to be called / named.
*Parecer* — to seem. *Parecerse a* — to resemble.
**Reciprocal use:** Plural reflexive forms can mean "each other."
*Nos queremos mucho.* — We love each other a lot.
*Se escriben todos los días.* — They write to each other every day.
"""
)
// MARK: - 6. Direct & Indirect Object Pronouns
private static let objectPronouns = GrammarNote(
id: "object-pronouns",
title: "Direct & Indirect Object Pronouns",
category: "Pronouns",
body: """
Object pronouns replace nouns to avoid repetition. Spanish distinguishes between direct objects (what receives the action) and indirect objects (who benefits from or is affected by the action).
**Direct object pronouns (DOPs):** *me, te, lo/la, nos, os, los/las*. They replace the thing or person directly acted upon.
*¿Tienes el libro? Sí, lo tengo.* — Do you have the book? Yes, I have it.
*Compré las flores. Las compré ayer.* — I bought the flowers. I bought them yesterday.
*No la conozco.* — I don't know her.
**Indirect object pronouns (IOPs):** *me, te, le, nos, os, les*. They indicate to whom or for whom the action is done.
*Le doy el regalo a María.* — I give the gift to María.
*Nos escribió una carta.* — He wrote us a letter.
*Te compré un café.* — I bought you a coffee.
**Placement rules:** Object pronouns go before a conjugated verb, or attach to infinitives, gerunds, and affirmative commands.
*Lo veo.* — I see it/him.
*Voy a verlo.* / *Lo voy a ver.* — I'm going to see it/him.
*Estoy leyéndolo.* / *Lo estoy leyendo.* — I'm reading it.
*¡Dámelo!* — Give it to me!
**Double object pronouns:** When both appear, the IOP comes first. When both are third person, *le/les* changes to *se*.
*Se lo dije.* — I told it to him/her (not *le lo*).
*¿El libro? Se lo presté a Juan.* — The book? I lent it to Juan.
*Me las regaló.* — She gave them to me.
**Common mistake — redundant le:** In Spanish, the indirect object pronoun often appears even when the noun is stated.
*Le di el libro a María.* — I gave the book to María (the *le* is required even with *a María*).
**Personal a:** When the direct object is a person, add *a* before them: *Veo a mi madre* → *La veo*. This *a* has no English translation.
"""
)
// MARK: - 7. Gustar-like Verbs
private static let gustarLikeVerbs = GrammarNote(
id: "gustar-like-verbs",
title: "Gustar-like Verbs",
category: "Verb Types",
body: """
*Gustar* and similar verbs work "backwards" compared to English. Instead of "I like pizza," Spanish says "Pizza is pleasing to me" — the thing liked is the grammatical subject.
**Structure:** Indirect object pronoun + *gusta/gustan* + subject (the thing liked).
*Me gusta el chocolate.* — I like chocolate (chocolate is pleasing to me).
*Me gustan los perros.* — I like dogs (dogs are pleasing to me).
*Te gusta bailar.* — You like to dance.
*Nos gusta la música.* — We like music.
*Les gustan las películas.* — They like movies.
**Only two main forms:** Use *gusta* with singular nouns and infinitives. Use *gustan* with plural nouns.
*Me gusta leer y escribir.* — I like to read and write (infinitives = gusta).
*¿Te gustan estos zapatos?* — Do you like these shoes? (plural = gustan).
**Clarifying with "a":** Since *le* and *les* can refer to multiple people, add *a + name/pronoun* for clarity.
*A ella le gusta el café.* — She likes coffee.
*A mis padres les encanta viajar.* — My parents love to travel.
*A mí me gusta, pero a ti no te gusta.* — I like it, but you don't.
**Other verbs that work like gustar:**
*Encantar* — to love/delight: *Me encanta esta canción.* — I love this song.
*Molestar* — to bother: *Le molesta el ruido.* — The noise bothers him.
*Importar* — to matter: *No me importa el precio.* — The price doesn't matter to me.
*Interesar* — to interest: *Nos interesan las ciencias.* — Sciences interest us.
*Faltar* — to lack/be missing: *Me faltan dos páginas.* — I'm missing two pages.
*Doler* — to hurt: *Me duele la cabeza.* — My head hurts.
*Parecer* — to seem: *Me parece una buena idea.* — It seems like a good idea to me.
*Quedar* — to remain/have left: *No nos queda tiempo.* — We don't have time left.
*Fascinar* — to fascinate: *Les fascinan los dinosaurios.* — Dinosaurs fascinate them.
"""
)
// MARK: - 8. Comparatives & Superlatives
private static let comparativesSuperlatives = GrammarNote(
id: "comparatives-superlatives",
title: "Comparatives & Superlatives",
category: "Adjectives",
body: """
Comparatives and superlatives let you compare people, things, and actions. Spanish uses *más* (more), *menos* (less), and *tan* (as) instead of English suffixes like "-er" and "-est."
**Comparatives of inequality:** *más/menos + adjective + que*
*María es más alta que Juan.* — María is taller than Juan.
*Este libro es menos interesante que el otro.* — This book is less interesting than the other one.
*Ella corre más rápido que yo.* — She runs faster than me.
*Tengo más amigos que tú.* — I have more friends than you.
**Comparatives of equality:** *tan + adjective + como*
*Pedro es tan inteligente como su hermana.* — Pedro is as smart as his sister.
*Esta casa es tan grande como la tuya.* — This house is as big as yours.
*No soy tan rápido como tú.* — I'm not as fast as you.
For nouns, use *tanto/tanta/tantos/tantas + noun + como*:
*Tengo tantos libros como ella.* — I have as many books as she does.
*No hay tanta gente como ayer.* — There aren't as many people as yesterday.
**Superlatives:** *el/la/los/las + más/menos + adjective + de*
*Es la ciudad más bonita de España.* — It's the most beautiful city in Spain.
*Él es el estudiante más trabajador de la clase.* — He is the hardest-working student in the class.
*Es el restaurante menos caro del barrio.* — It's the least expensive restaurant in the neighborhood.
**Absolute superlative:** Add *-ísimo/a* for "extremely" or "very."
*La comida está riquísima.* — The food is extremely delicious.
*Estoy cansadísimo.* — I am extremely tired.
**Irregular forms — these do NOT use más:**
*Bueno → mejor (better) → el mejor (the best)*
*Malo → peor (worse) → el peor (the worst)*
*Grande → mayor (older/greater) → el mayor (the oldest/greatest)*
*Pequeño → menor (younger/lesser) → el menor (the youngest/least)*
*Mi hermano mayor vive en Barcelona.* — My older brother lives in Barcelona.
*Esta película es mejor que la anterior.* — This movie is better than the previous one.
*Es el peor día de mi vida.* — It's the worst day of my life.
"""
)
// MARK: - 9. Conditional & "If" Clauses
private static let conditionalIfClauses = GrammarNote(
id: "conditional-if-clauses",
title: "Conditional & \"If\" Clauses",
category: "Verb Tenses",
body: """
Spanish conditional sentences (*oraciones condicionales*) follow specific patterns depending on how likely the condition is. There are three main types, each requiring a different verb tense combination.
**Type 1 — Real / Possible conditions:** Use *si* + present indicative in the "if" clause, and the present or future tense in the result clause. These describe situations that are likely or possible.
*Si llueve, me quedo en casa.* — If it rains, I stay home.
*Si estudias mucho, aprobarás el examen.* — If you study a lot, you will pass the exam.
*Si tienes tiempo, llámame.* — If you have time, call me.
*Si no comes, vas a tener hambre.* — If you don't eat, you're going to be hungry.
**Type 2 — Hypothetical / Unlikely conditions:** Use *si* + imperfect subjunctive in the "if" clause, and the conditional tense in the result clause. These describe situations that are unlikely, imaginary, or contrary to the present reality.
*Si tuviera dinero, viajaría por el mundo.* — If I had money, I would travel the world.
*Si yo fuera tú, no haría eso.* — If I were you, I wouldn't do that.
*Si hablara español perfectamente, viviría en España.* — If I spoke Spanish perfectly, I would live in Spain.
*Si pudiéramos volar, no necesitaríamos aviones.* — If we could fly, we wouldn't need airplanes.
**Type 3 — Past unreal / Impossible conditions:** Use *si* + pluperfect subjunctive in the "if" clause, and the conditional perfect in the result clause. These describe situations that did not happen in the past.
*Si hubiera estudiado, habría aprobado.* — If I had studied, I would have passed.
*Si hubiéramos salido antes, habríamos llegado a tiempo.* — If we had left earlier, we would have arrived on time.
*Si no hubiera llovido, habríamos ido a la playa.* — If it hadn't rained, we would have gone to the beach.
**Key rule:** Never use the present subjunctive or the conditional after *si*. You will never say *si tendría* or *si tenga* — those are always incorrect.
**Mixed conditionals:** Occasionally, you can mix Type 2 and Type 3 when the past condition affects the present: *Si hubiera desayunado, no tendría hambre ahora* — If I had eaten breakfast, I wouldn't be hungry now.
"""
)
// MARK: - 10. Commands (Imperative Mood)
private static let commandsImperative = GrammarNote(
id: "commands-imperative",
title: "Commands (Imperative Mood)",
category: "Verb Tenses",
body: """
The imperative mood (*el imperativo*) is used to give commands, instructions, or requests. Spanish has different command forms depending on who you are addressing and whether the command is affirmative or negative.
**Affirmative tú commands** are usually the same as the third-person singular present indicative: *habla* (speak), *come* (eat), *escribe* (write).
*Habla más despacio, por favor.* — Speak more slowly, please.
*Come tus verduras.* — Eat your vegetables.
*Abre la puerta.* — Open the door.
**Eight irregular tú commands** must be memorized: *di* (decir), *haz* (hacer), *ve* (ir), *pon* (poner), *sal* (salir), *sé* (ser), *ten* (tener), *ven* (venir).
*Haz tu tarea ahora.* — Do your homework now.
*Ven aquí, por favor.* — Come here, please.
*Pon la mesa antes de cenar.* — Set the table before dinner.
*Di la verdad.* — Tell the truth.
**Negative tú commands** use the present subjunctive form — they are different from affirmative commands.
*No hables tan rápido.* — Don't speak so fast.
*No comas eso.* — Don't eat that.
*No vayas solo.* — Don't go alone.
*No hagas ruido.* — Don't make noise.
**Usted/Ustedes commands** use the present subjunctive for both affirmative and negative forms.
*Hable con el gerente, por favor.* — Speak with the manager, please (usted).
*No se preocupen.* — Don't worry (ustedes).
**Pronoun attachment rules:** In affirmative commands, pronouns attach to the end of the verb. In negative commands, pronouns go before the verb.
*Dímelo.* — Tell it to me.
*No me lo digas.* — Don't tell it to me.
*Siéntate aquí.* — Sit here.
*No te sientes ahí.* — Don't sit there.
*Cómpramelo.* — Buy it for me.
*No me lo compres.* — Don't buy it for me.
**Accent marks:** When attaching pronouns to an affirmative command, add an accent mark to preserve the original stress: *dime* → *dímelo*, *sienta* → *siéntate*.
"""
)
// MARK: - 11. Saber vs Conocer
private static let saberVsConocer = GrammarNote(
id: "saber-vs-conocer",
title: "Saber vs Conocer",
category: "Core Concepts",
body: """
Spanish has two verbs meaning "to know": *saber* and *conocer*. They are not interchangeable — each refers to a different type of knowledge.
**Saber** is used for facts, information, and learned skills. It answers "Do you know this piece of information?" or "Do you know how to do this?"
*Sé la respuesta.* — I know the answer.
*¿Sabes dónde está el banco?* — Do you know where the bank is?
*Ella sabe hablar tres idiomas.* — She knows how to speak three languages.
*Sé nadar.* — I know how to swim.
*No sabemos cuándo llega el tren.* — We don't know when the train arrives.
**Conocer** is used for familiarity with people, places, and things. It implies personal experience or acquaintance rather than factual knowledge.
*Conozco Madrid muy bien.* — I know Madrid very well.
*¿Conoces a mi hermana?* — Do you know my sister?
*Conocemos ese restaurante.* — We know (are familiar with) that restaurant.
*No conozco esa canción.* — I don't know (am not familiar with) that song.
*Ella conoce la obra de Picasso.* — She is familiar with Picasso's work.
**Key difference:** *Saber* = information in your head. *Conocer* = personal experience or acquaintance. You can *saber* a fact, but you *conocer* a person.
*Sé que Madrid es la capital de España.* — I know that Madrid is the capital of Spain (fact).
*Conozco Madrid porque viví allí dos años.* — I know Madrid because I lived there for two years (familiarity).
**Meaning changes in the preterite:** Both verbs shift meaning when used in the preterite tense, because the completed action implies a starting point.
*Supe la verdad ayer.* — I found out the truth yesterday (completed act of learning).
*Conocí a tu hermana ayer.* — I met your sister yesterday (first encounter).
*Supimos que estaba enferma.* — We found out she was sick.
*Conocieron la ciudad durante el viaje.* — They got to know the city during the trip.
**Remember:** *Conocer* always takes the personal *a* before people: *Conozco a tu profesor* — I know your teacher. *Saber* never uses the personal *a*.
"""
)
// MARK: - 12. Double Negatives
private static let doubleNegatives = GrammarNote(
id: "double-negatives",
title: "Double Negatives",
category: "Sentence Structure",
body: """
Unlike English, where double negatives are considered incorrect, Spanish requires them. When a negative word follows the verb, *no* must appear before the verb as well. This is standard, correct Spanish grammar.
**The core rule:** If the negative word comes after the verb, place *no* before the verb. If the negative word comes before the verb, do not add *no*.
**No...nada** (nothing):
*No quiero nada.* — I don't want anything / I want nothing.
*Nada me sorprende.* — Nothing surprises me.
**No...nunca / no...jamás** (never):
*No como carne nunca.* — I never eat meat.
*Nunca llueve en el desierto.* — It never rains in the desert.
*No he ido jamás a Japón.* — I have never been to Japan.
**No...nadie** (nobody):
*No vi a nadie en la calle.* — I didn't see anyone on the street.
*Nadie sabe la respuesta.* — Nobody knows the answer.
*No le dijo a nadie.* — He didn't tell anyone.
**No...ninguno/ningún/ninguna** (no, not any, none):
*No tengo ningún problema.* — I don't have any problem.
*Ninguna tienda está abierta.* — No store is open.
*No queda ninguno.* — There are none left.
**No...tampoco** (neither, not either):
*No me gusta tampoco.* — I don't like it either.
*Tampoco quiero ir.* — I don't want to go either.
*Ella no fue y yo tampoco.* — She didn't go and I didn't either.
**No...ni...ni** (neither...nor):
*No tengo ni tiempo ni dinero.* — I have neither time nor money.
*Ni come ni deja comer.* — He neither eats nor lets others eat (proverb).
*No quiero ni café ni té.* — I want neither coffee nor tea.
**Multiple negatives in one sentence** are perfectly natural in Spanish:
*No le dije nada a nadie nunca.* — I never said anything to anyone.
*Nadie sabe nada de ningún problema.* — Nobody knows anything about any problem.
**Common mistake for English speakers:** Saying *No quiero algo* (incorrect) instead of *No quiero nada* (correct). After *no*, always use the negative counterpart: *algo* → *nada*, *alguien* → *nadie*, *alguno* → *ninguno*, *siempre* → *nunca*, *también* → *tampoco*.
"""
)
// MARK: - 13. Adjective Placement
private static let adjectivePlacement = GrammarNote(
id: "adjective-placement",
title: "Adjective Placement",
category: "Sentence Structure",
body: """
In English, adjectives almost always come before the noun. In Spanish, the default position is after the noun, but placing an adjective before the noun adds a subjective, emotional, or emphatic quality. Some adjectives even change meaning depending on their position.
**Default position — after the noun:** Most adjectives follow the noun and provide objective, distinguishing information.
*Un coche rojo.* — A red car.
*Una casa grande.* — A big house.
*Un hombre inteligente.* — An intelligent man.
*Quiero agua fría.* — I want cold water.
*Es una decisión importante.* — It's an important decision.
**Before the noun — subjective or emotional quality:** Placing the adjective before the noun emphasizes a quality the speaker considers inherent or expresses a personal judgment.
*Una hermosa ciudad.* — A beautiful city (admiration).
*Un gran hombre.* — A great man (character).
*Los pequeños detalles importan.* — The little details matter (emphasis).
**Adjectives that change meaning by position:** This is one of the trickiest areas of Spanish grammar.
*Pobre:* *El pobre hombre* — The unfortunate man. *El hombre pobre* — The man who has no money.
*Viejo:* *Un viejo amigo* — A longstanding friend. *Un amigo viejo* — An elderly friend.
*Nuevo:* *Un nuevo coche* — A different car (new to the owner). *Un coche nuevo* — A brand-new car.
*Mismo:* *El mismo problema* — The same problem. *El problema mismo* — The problem itself.
*Grande:* *Una gran mujer* — A great woman. *Una mujer grande* — A large/big woman.
*Único:* *La única oportunidad* — The only opportunity. *Una oportunidad única* — A unique opportunity.
**Shortened forms before masculine singular nouns:** Some adjectives lose their final -o when placed before a masculine singular noun.
*Bueno → buen:* *Es un buen libro.* — It's a good book.
*Malo → mal:* *Hace mal tiempo.* — The weather is bad.
*Grande → gran:* *Es un gran momento.* — It's a great moment (works for both genders).
*Primero → primer:* *El primer día de clase.* — The first day of class.
*Tercero → tercer:* *El tercer piso.* — The third floor.
**Adjectives that always go before the noun:** Quantifiers and numbers: *mucho, poco, otro, cada, ambos, varios*. *Hay muchas personas aquí.* — There are many people here. *Quiero otra oportunidad.* — I want another chance.
"""
)
// MARK: - 14. Tener Expressions
private static let tenerExpressions = GrammarNote(
id: "tener-expressions",
title: "Tener Expressions",
category: "Core Concepts",
body: """
One of the most common traps for English speakers is translating "I am hungry" or "I am 25 years old" directly. Spanish uses *tener* (to have) where English uses "to be" for many physical states and conditions. You literally "have hunger" rather than "are hungry."
**Age:** *Tener + number + años*
*Tengo veinticinco años.* — I am twenty-five years old.
*¿Cuántos años tienes?* — How old are you?
*Mi abuela tiene ochenta y dos años.* — My grandmother is eighty-two years old.
**Physical sensations:**
*Tener hambre* — to be hungry: *Tengo mucha hambre.* — I am very hungry.
*Tener sed* — to be thirsty: *¿Tienes sed? Hay agua en la nevera.* — Are you thirsty? There's water in the fridge.
*Tener calor* — to be hot: *Tenemos calor porque no hay aire acondicionado.* — We are hot because there's no air conditioning.
*Tener frío* — to be cold: *¿Tienes frío? Te presto mi chaqueta.* — Are you cold? I'll lend you my jacket.
*Tener sueño* — to be sleepy: *Los niños tienen sueño después de jugar.* — The kids are sleepy after playing.
**Emotional states and situations:**
*Tener miedo* — to be afraid: *Ella tiene miedo de las arañas.* — She is afraid of spiders.
*Tener razón* — to be right: *Tienes razón, es mejor esperar.* — You're right, it's better to wait.
*Tener suerte* — to be lucky: *Tuvimos mucha suerte con el clima.* — We were very lucky with the weather.
*Tener prisa* — to be in a hurry: *Tengo prisa, hablamos luego.* — I'm in a hurry, we'll talk later.
*Tener cuidado* — to be careful: *Ten cuidado con el hielo.* — Be careful with the ice.
**Other important tener expressions:**
*Tener ganas de* — to feel like: *Tengo ganas de bailar.* — I feel like dancing.
*Tener que + infinitive* — to have to: *Tenemos que irnos ya.* — We have to leave now.
*Tener la culpa* — to be at fault: *No tengo la culpa de nada.* — It's not my fault.
*Tener sentido* — to make sense: *Eso no tiene sentido.* — That doesn't make sense.
*Tener éxito* — to be successful: *La película tuvo mucho éxito.* — The movie was very successful.
**Important:** Because these are nouns, use *mucho/mucha* (not *muy*) to intensify them: *Tengo mucha hambre* (correct), not *Tengo muy hambre* (incorrect).
"""
)
// MARK: - 15. The Personal "A"
private static let personalA = GrammarNote(
id: "personal-a",
title: "The Personal \"A\"",
category: "Sentence Structure",
body: """
Spanish requires the preposition *a* before direct objects that are specific people. This is called the "personal a" (*a personal*). It has no English translation — it simply signals that a person is the direct object of the verb.
**Basic rule:** When the direct object is a specific, known person, place *a* before them.
*Veo a María.* — I see María.
*Llamo a mi madre todos los días.* — I call my mother every day.
*Conozco a tu profesor.* — I know your teacher.
*Extraño a mis amigos.* — I miss my friends.
*Invité a Juan y a Sofía a la fiesta.* — I invited Juan and Sofía to the party.
**Without personal a — non-human direct objects:** When the direct object is a thing, no *a* is needed.
*Veo la mesa.* — I see the table.
*Busco mis llaves.* — I'm looking for my keys.
*Leo un libro.* — I'm reading a book.
*Compré una casa.* — I bought a house.
**Pets and personified things:** The personal *a* extends to beloved pets and things treated as people.
*Llevo a mi perro al veterinario.* — I take my dog to the vet.
*Los niños quieren a su gato.* — The kids love their cat.
**NOT used after tener:** This is one of the most important exceptions. *Tener* does not take the personal *a*, even when referring to people.
*Tengo dos hermanos.* — I have two siblings (no *a*).
*Tiene tres hijos.* — She has three children (no *a*).
*Tenemos un nuevo vecino.* — We have a new neighbor (no *a*).
**NOT used with indefinite or non-specific people:** When you refer to an unspecified person, omit the personal *a*.
*Busco un doctor.* — I'm looking for a doctor (any doctor, non-specific).
*Busco a un doctor.* — I'm looking for a (specific) doctor (I know which one).
*Necesito un traductor.* — I need a translator (any translator).
*Necesito a mi traductor.* — I need my translator (specific person).
**With nadie, alguien, and quién:** The personal *a* is used with these indefinite pronouns referring to people.
*No veo a nadie.* — I don't see anyone.
*¿Conoces a alguien aquí?* — Do you know anyone here?
*¿A quién llamaste?* — Whom did you call?
**Memory aid:** If the direct object can be replaced by a person's name, it probably needs the personal *a*. "I see ___" → if you could fill in "María," then use *a*.
"""
)
// MARK: - 16. Relative Pronouns
private static let relativePronouns = GrammarNote(
id: "relative-pronouns",
title: "Relative Pronouns",
category: "Sentence Structure",
body: """
Relative pronouns connect a subordinate clause to a main clause, providing additional information about a noun. Spanish has several relative pronouns, each with specific uses.
**Que** — the most common relative pronoun, meaning "that," "which," or "who." It can refer to people or things and is used in both restrictive and non-restrictive clauses.
*La chica que vive aquí es mi amiga.* — The girl who lives here is my friend.
*El libro que compré es muy bueno.* — The book that I bought is very good.
*La película que vimos anoche fue aburrida.* — The movie that we saw last night was boring.
*Los estudiantes que estudian aprueban.* — Students who study pass.
**Quien / Quienes** — used for people only, especially after prepositions. *Quien* is singular, *quienes* is plural.
*La persona con quien hablé es la directora.* — The person with whom I spoke is the director.
*Los amigos a quienes invité no vinieron.* — The friends whom I invited didn't come.
*Quien mucho abarca, poco aprieta.* — He who grasps at too much holds little (proverb).
*Fue mi madre quien me lo dijo.* — It was my mother who told me.
**Lo que** — means "what" or "that which." It refers to abstract ideas, actions, or entire concepts rather than specific nouns.
*No entiendo lo que dices.* — I don't understand what you're saying.
*Lo que necesitas es descansar.* — What you need is to rest.
*Haz lo que quieras.* — Do what you want.
*Lo que pasó ayer fue increíble.* — What happened yesterday was incredible.
**El cual / La cual / Los cuales / Las cuales** — more formal alternatives to *que*, typically used after prepositions, especially compound prepositions. They agree in gender and number with their antecedent.
*La casa en la cual nací ya no existe.* — The house in which I was born no longer exists.
*Los problemas por los cuales renunció eran graves.* — The problems for which he resigned were serious.
*La razón por la cual te llamé es importante.* — The reason for which I called you is important.
**Cuyo / Cuya / Cuyos / Cuyas** — means "whose." It agrees in gender and number with the thing possessed, not the possessor.
*El hombre cuya hija conoces es mi vecino.* — The man whose daughter you know is my neighbor.
*La autora cuyos libros he leído viene mañana.* — The author whose books I've read is coming tomorrow.
*El país cuyo idioma estudio es España.* — The country whose language I study is Spain.
**Donde** — means "where" and is used for places. It can be replaced by *en que* or *en el cual*.
*La ciudad donde nací es pequeña.* — The city where I was born is small.
*El restaurante donde comimos estaba lleno.* — The restaurant where we ate was full.
"""
)
// MARK: - 17. Future vs "Ir a + Infinitive"
private static let futureVsIrA = GrammarNote(
id: "future-vs-ir-a",
title: "Future vs \"Ir a + Infinitive\"",
category: "Verb Tenses",
body: """
Spanish has two main ways to express future actions: the simple future tense (*futuro simple*) and the construction *ir a + infinitive*. While both refer to the future, they carry different nuances and are used in different contexts.
**Ir a + infinitive** — the informal, conversational future. It expresses plans, intentions, and actions about to happen. This is the most common way to talk about the future in everyday speech.
*Voy a comer con mis amigos.* — I'm going to eat with my friends.
*¿Qué vas a hacer este fin de semana?* — What are you going to do this weekend?
*Ella va a estudiar medicina.* — She's going to study medicine.
*Vamos a llegar tarde si no nos apuramos.* — We're going to be late if we don't hurry.
*Van a construir un nuevo hospital aquí.* — They're going to build a new hospital here.
**Simple future tense** — more formal, used for predictions, promises, firm resolutions, and probability. It is formed by adding endings (-é, -ás, -á, -emos, -éis, -án) to the infinitive.
*Mañana lloverá todo el día.* — Tomorrow it will rain all day (prediction).
*Te prometo que vendré temprano.* — I promise I will come early (promise).
*Algún día hablaré español con fluidez.* — Someday I will speak Spanish fluently (resolution).
*Estudiaré mucho para el examen final.* — I will study a lot for the final exam.
**Future of probability** — a unique use of the simple future to express conjecture or wonder about the present. This has no equivalent in English grammar.
*¿Qué hora será?* — I wonder what time it is (lit. "what time will it be?").
*¿Dónde estará mi teléfono?* — Where could my phone be?
*Costará unos cincuenta euros.* — It probably costs around fifty euros.
*Tendrá unos treinta años.* — He must be around thirty years old.
*Será verdad, pero no me lo creo.* — It may be true, but I don't believe it.
**When to use which:** In conversation, *ir a + infinitive* is much more common than the simple future. Think of it like English: "I'm going to call you" (casual) vs "I shall contact you" (formal). The simple future sounds slightly more distant or formal in everyday speech.
*Voy a llamarte luego.* — I'm going to call you later (casual, planned).
*Te llamaré cuando llegue.* — I will call you when I arrive (more formal or uncertain).
**Irregular future stems** (used with both future and conditional): *tener → tendr-, salir → saldr-, venir → vendr-, poner → pondr-, poder → podr-, saber → sabr-, hacer → har-, decir → dir-, querer → querr-, haber → habr-*.
"""
)
// MARK: - 18. Accent Marks & Stress Rules
private static let accentMarksStress = GrammarNote(
id: "accent-marks-stress",
title: "Accent Marks & Stress Rules",
category: "Pronunciation",
body: """
Spanish pronunciation follows predictable stress rules. Once you learn these patterns, you can correctly pronounce any word — and the written accent mark (*tilde*) tells you when a word breaks the pattern.
**Rule 1 — Words ending in a vowel, -n, or -s:** The stress falls on the second-to-last syllable. These are called *palabras llanas* (or *graves*).
*hablo* — HAB-lo (I speak)
*comen* — CO-men (they eat)
*zapatos* — za-PA-tos (shoes)
*examen* — e-XA-men (exam)
*joven* — JO-ven (young)
**Rule 2 — Words ending in a consonant (other than -n or -s):** The stress falls on the last syllable. These are called *palabras agudas*.
*hablar* — hab-LAR (to speak)
*ciudad* — ciu-DAD (city)
*español* — es-pa-ÑOL (Spanish)
*reloj* — re-LOJ (clock)
*pared* — pa-RED (wall)
**Rule 3 — The accent mark overrides the default:** When a word does not follow the default pattern, an accent mark shows where the stress actually falls.
*café* — ca-FÉ (coffee — ends in vowel but stress is on the last syllable)
*teléfono* — te-LÉ-fo-no (telephone — stress on third-to-last syllable)
*médico* — MÉ-di-co (doctor — stress on third-to-last syllable)
*canción* — can-CIÓN (song — ends in -n but stress is on last syllable)
*difícil* — di-FÍ-cil (difficult — ends in consonant but stress is on second-to-last)
**Accent marks that distinguish meaning:** Some one-syllable words use accents to differentiate between two meanings. These are called *tildes diacríticas*.
*si* (if) / *sí* (yes): *Si dices que sí, vamos.* — If you say yes, let's go.
*el* (the) / *él* (he): *Él tiene el libro.* — He has the book.
*tu* (your) / *tú* (you): *Tú tienes tu mochila.* — You have your backpack.
*se* (reflexive pronoun) / *sé* (I know / be): *Yo sé que se fue.* — I know he left.
*de* (of/from) / *dé* (give - subjunctive): *Quiero que me dé un poco de café.* — I want him to give me some coffee.
*mas* (but - literary) / *más* (more): *Quiero más, mas no puedo.* — I want more, but I can't.
**Question words always have accents:** *qué* (what), *cómo* (how), *dónde* (where), *cuándo* (when), *cuál* (which), *quién* (who), *cuánto* (how much). Compare: *Sé cuando llega* (I know when he arrives) vs *¿Cuándo llega?* (When does he arrive?).
"""
)
// MARK: - 19. "Se" Constructions
private static let seConstructions = GrammarNote(
id: "se-constructions",
title: "\"Se\" Constructions",
category: "Advanced",
body: """
The word *se* is one of the most versatile and confusing words in Spanish. It appears in at least five distinct grammatical constructions, each with different meaning and usage. Understanding these patterns is essential for intermediate and advanced learners.
**1. Reflexive se — the subject acts on itself.** The action reflects back to the subject. The verb is conjugated with reflexive pronouns (*me, te, se, nos, os, se*).
*Ella se lava las manos.* — She washes her hands (herself).
*Me levanto a las siete.* — I get up at seven.
*Se vistieron rápidamente.* — They got dressed quickly.
*El gato se lame las patas.* — The cat licks its paws.
**2. Reciprocal se — subjects act on each other.** Used with plural subjects to express mutual action.
*Se aman mucho.* — They love each other a lot.
*Nos vemos todos los viernes.* — We see each other every Friday.
*Se escriben cartas largas.* — They write long letters to each other.
*Se abrazaron al despedirse.* — They hugged each other when saying goodbye.
**3. Impersonal se — no specific subject.** Used to make general statements about what "people" or "one" does. The verb is always third-person singular.
*Se habla español aquí.* — Spanish is spoken here / One speaks Spanish here.
*Se dice que va a llover.* — They say it's going to rain.
*¿Cómo se dice esto en español?* — How do you say this in Spanish?
*En España se cena muy tarde.* — In Spain, people eat dinner very late.
*Se vive bien en esta ciudad.* — One lives well in this city.
**4. Passive se — the subject receives the action.** Similar to English passive voice, but the verb agrees with the grammatical subject (the thing acted upon).
*Se venden casas.* — Houses are sold / Houses for sale.
*Se necesitan profesores.* — Teachers are needed.
*Se abrió una nueva tienda.* — A new store was opened.
*Aquí se reparan computadoras.* — Computers are repaired here.
*Se hablan varios idiomas en Suiza.* — Several languages are spoken in Switzerland.
**5. Accidental se — unplanned events.** This construction expresses that something happened accidentally or unintentionally. The structure is: *se + indirect object pronoun + verb + subject*.
*Se me cayó el vaso.* — I dropped the glass (accidentally).
*Se le olvidaron las llaves.* — He forgot the keys (they slipped his mind).
*Se nos rompió la ventana.* — The window broke on us (by accident).
*Se te perdió el teléfono.* — You lost your phone (unintentionally).
*Se me acabó la paciencia.* — I ran out of patience.
**How to tell them apart:** Look at the subject, the number of the verb, and whether the action is intentional. Context is key — the same sentence can have different meanings: *Se lavan* could mean "they wash themselves" (reflexive) or "they wash each other" (reciprocal).
"""
)
// MARK: - 20. Estar + Gerund (Progressive Tenses)
private static let estarGerundProgressive = GrammarNote(
id: "estar-gerund-progressive",
title: "Estar + Gerund (Progressive Tenses)",
category: "Verb Tenses",
body: """
The progressive tenses in Spanish are formed with *estar* + the gerund (*gerundio*). They emphasize that an action is in progress at a specific moment, much like English "-ing" forms, but with important differences in usage.
**Present progressive:** *Estar* (present) + gerund. Describes what is happening right now.
*Estoy hablando por teléfono.* — I am speaking on the phone.
*¿Qué estás haciendo?* — What are you doing?
*Ella está estudiando para el examen.* — She is studying for the exam.
*Estamos esperando el autobús.* — We are waiting for the bus.
*Los niños están jugando en el parque.* — The kids are playing in the park.
**Gerund formation — regular verbs:**
-ar verbs → *-ando*: hablar → *hablando*, estudiar → *estudiando*, caminar → *caminando*
-er verbs → *-iendo*: comer → *comiendo*, beber → *bebiendo*, aprender → *aprendiendo*
-ir verbs → *-iendo*: vivir → *viviendo*, escribir → *escribiendo*, abrir → *abriendo*
**Irregular gerunds** — some common verbs have stem changes or spelling adjustments:
*Decir → diciendo* (saying). *Dormir → durmiendo* (sleeping). *Leer → leyendo* (reading). *Ir → yendo* (going). *Pedir → pidiendo* (asking). *Seguir → siguiendo* (following). *Morir → muriendo* (dying). *Poder → pudiendo* (being able). *Venir → viniendo* (coming). *Oír → oyendo* (hearing).
**Past progressive:** *Estar* (imperfect) + gerund. Describes what was in progress at a past moment.
*Estaba leyendo cuando llegaste.* — I was reading when you arrived.
*Estábamos cenando a las nueve.* — We were having dinner at nine.
*¿Qué estabas haciendo anoche?* — What were you doing last night?
**Key difference from English — NOT used for future plans:** In English, "I'm leaving tomorrow" uses the progressive for a future plan. In Spanish, you cannot do this. Use the present tense or *ir a + infinitive* instead.
*Me voy mañana.* — I'm leaving tomorrow (correct — present tense).
*Voy a salir mañana.* — I'm going to leave tomorrow (correct — ir a).
*Estoy yendo mañana.* — (INCORRECT — do not use progressive for future plans).
**Pronoun placement:** Pronouns can either attach to the gerund or go before *estar*. Both positions are correct.
*Estoy haciéndolo.* / *Lo estoy haciendo.* — I am doing it.
*Está diciéndomelo.* / *Me lo está diciendo.* — He is telling it to me.
*Estamos buscándola.* / *La estamos buscando.* — We are looking for her.
**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.
"""
)
}

View File

@@ -0,0 +1,40 @@
import SwiftData
import Foundation
@Model
final class IrregularSpan {
var verbId: Int = 0
var tenseId: String = ""
var personIndex: Int = 0
var spanType: Int = 0
var pattern: Int = 0
var start: Int = 0
var end: Int = 0
var verbForm: VerbForm?
init(verbId: Int, tenseId: String, personIndex: Int, spanType: Int, pattern: Int, start: Int, end: Int) {
self.verbId = verbId
self.tenseId = tenseId
self.personIndex = personIndex
self.spanType = spanType
self.pattern = pattern
self.start = start
self.end = end
}
var category: SpanCategory {
switch spanType {
case 100..<200: return .spelling
case 200..<300: return .stemChange
case 300..<400: return .uniqueIrregular
default: return .uniqueIrregular
}
}
enum SpanCategory: String {
case spelling = "Spelling Change"
case stemChange = "Stem Change"
case uniqueIrregular = "Unique Irregular"
}
}

View File

@@ -0,0 +1,86 @@
import Foundation
import SharedModels
enum QuizType: String, CaseIterable, Identifiable, Sendable {
case mcEnToEs = "mc_en_to_es"
case mcEsToEn = "mc_es_to_en"
case typingEnToEs = "typing_en_to_es"
case typingEsToEn = "typing_es_to_en"
case handwritingEnToEs = "hw_en_to_es"
case handwritingEsToEn = "hw_es_to_en"
var id: String { rawValue }
var label: String {
switch self {
case .mcEnToEs: "Multiple Choice: EN → ES"
case .mcEsToEn: "Multiple Choice: ES → EN"
case .typingEnToEs: "Fill in the Blank: EN → ES"
case .typingEsToEn: "Fill in the Blank: ES → EN"
case .handwritingEnToEs: "Handwriting: EN → ES"
case .handwritingEsToEn: "Handwriting: ES → EN"
}
}
var icon: String {
switch self {
case .mcEnToEs, .mcEsToEn: "list.bullet"
case .typingEnToEs, .typingEsToEn: "keyboard"
case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline"
}
}
var description: String {
switch self {
case .mcEnToEs: "See English, pick the Spanish translation"
case .mcEsToEn: "See Spanish, pick the English translation"
case .typingEnToEs: "See English, type the Spanish word"
case .typingEsToEn: "See Spanish, type the English word"
case .handwritingEnToEs: "See English, handwrite the Spanish word"
case .handwritingEsToEn: "See Spanish, handwrite the English word"
}
}
/// What's shown as the prompt
var promptLanguage: String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "Spanish"
}
}
var answerLanguage: String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "Spanish"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "English"
}
}
var isMultipleChoice: Bool {
switch self {
case .mcEnToEs, .mcEsToEn: true
case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false
}
}
var isHandwriting: Bool {
switch self {
case .handwritingEnToEs, .handwritingEsToEn: true
default: false
}
}
func prompt(for card: VocabCard) -> String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.front
}
}
func answer(for card: VocabCard) -> String {
switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.front
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.back
}
}
}

View File

@@ -0,0 +1,57 @@
import SwiftData
import Foundation
@Model
final class ReviewCard {
var key: String = ""
var verbId: Int = 0
var tenseId: String = ""
var personIndex: Int = 0
var easeFactor: Double = 2.5
var interval: Int = 0
var repetitions: Int = 0
var dueDate: Date = Date()
var lastReviewDate: Date?
init(verbId: Int, tenseId: String, personIndex: Int) {
self.key = Self.makeKey(verbId: verbId, tenseId: tenseId, personIndex: personIndex)
self.verbId = verbId
self.tenseId = tenseId
self.personIndex = personIndex
}
static func makeKey(verbId: Int, tenseId: String, personIndex: Int) -> String {
"\(verbId)|\(tenseId)|\(personIndex)"
}
func refreshIdentityIfNeeded() {
let expected = Self.makeKey(verbId: verbId, tenseId: tenseId, personIndex: personIndex)
if key != expected {
key = expected
}
}
}
@Model
final class CourseReviewCard {
var id: String = ""
var deckId: String = ""
var front: String = ""
var back: String = ""
var easeFactor: Double = 2.5
var interval: Int = 0
var repetitions: Int = 0
var dueDate: Date = Date()
var lastReviewDate: Date?
init(id: String, deckId: String, front: String, back: String) {
self.id = id
self.deckId = deckId
self.front = front
self.back = back
}
}

View File

@@ -0,0 +1,288 @@
import Foundation
/// Static conjugation ending tables for all 20 tenses, used in Guide views.
/// Data sourced from the Spanish Verb Tenses chart and Conjuu ES conjugation rules.
struct TenseEndingTable: Identifiable {
let id: String // tense ID
let formula: String // e.g. "stem + ending" or "haber + past participle"
let isCompound: Bool
let auxiliaryForms: [String]? // haber conjugations for compound tenses
let arEndings: [String] // 6 persons: yo, tú, él, nosotros, vosotros, ellos
let erEndings: [String]
let irEndings: [String]
let stemRule: String // e.g. "infinitive minus -ar", "full infinitive"
let arExample: (verb: String, stem: String)
let erExample: (verb: String, stem: String)
let irExample: (verb: String, stem: String)
static let persons = ["yo", "", "él/ella", "nosotros", "vosotros", "ellos/ellas"]
/// Build a full conjugation example for display
func exampleForms(for verbType: String) -> [(person: String, form: String)] {
let endings: [String]
let example: (verb: String, stem: String)
switch verbType {
case "ar": endings = arEndings; example = arExample
case "er": endings = erEndings; example = erExample
case "ir": endings = irEndings; example = irExample
default: return []
}
if isCompound, let aux = auxiliaryForms {
let participleSuffix = verbType == "ar" ? "ado" : "ido"
let participle = example.stem + participleSuffix
return zip(Self.persons, aux).map { person, auxForm in
(person, "\(auxForm) \(participle)")
}
}
return zip(Self.persons, endings).map { person, ending in
(person, example.stem + ending)
}
}
// MARK: - All tense tables
static let all: [TenseEndingTable] = [
// INDICATIVE
TenseEndingTable(
id: "ind_presente",
formula: "stem + ending",
isCompound: false, auxiliaryForms: nil,
arEndings: ["-o", "-as", "-a", "-amos", "-áis", "-an"],
erEndings: ["-o", "-es", "-e", "-emos", "-éis", "-en"],
irEndings: ["-o", "-es", "-e", "-imos", "-ís", "-en"],
stemRule: "Remove -ar/-er/-ir from infinitive",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "ind_preterito",
formula: "stem + ending",
isCompound: false, auxiliaryForms: nil,
arEndings: ["", "-aste", "", "-amos", "-asteis", "-aron"],
erEndings: ["", "-iste", "-ió", "-imos", "-isteis", "-ieron"],
irEndings: ["", "-iste", "-ió", "-imos", "-isteis", "-ieron"],
stemRule: "Remove -ar/-er/-ir from infinitive",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "ind_imperfecto",
formula: "stem + ending",
isCompound: false, auxiliaryForms: nil,
arEndings: ["-aba", "-abas", "-aba", "-ábamos", "-abais", "-aban"],
erEndings: ["-ía", "-ías", "-ía", "-íamos", "-íais", "-ían"],
irEndings: ["-ía", "-ías", "-ía", "-íamos", "-íais", "-ían"],
stemRule: "Remove -ar/-er/-ir from infinitive",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "ind_futuro",
formula: "infinitive + ending",
isCompound: false, auxiliaryForms: nil,
arEndings: ["", "-ás", "", "-emos", "-éis", "-án"],
erEndings: ["", "-ás", "", "-emos", "-éis", "-án"],
irEndings: ["", "-ás", "", "-emos", "-éis", "-án"],
stemRule: "Use full infinitive as stem",
arExample: ("hablar", "hablar"), erExample: ("comer", "comer"), irExample: ("vivir", "vivir")
),
TenseEndingTable(
id: "ind_perfecto",
formula: "haber (presente) + past participle",
isCompound: true,
auxiliaryForms: ["he", "has", "ha", "hemos", "habéis", "han"],
arEndings: ["-ado", "-ado", "-ado", "-ado", "-ado", "-ado"],
erEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
irEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
stemRule: "Past participle: stem + -ado (ar) / -ido (er/ir)",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "ind_pluscuamperfecto",
formula: "haber (imperfecto) + past participle",
isCompound: true,
auxiliaryForms: ["había", "habías", "había", "habíamos", "habíais", "habían"],
arEndings: ["-ado", "-ado", "-ado", "-ado", "-ado", "-ado"],
erEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
irEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
stemRule: "Past participle: stem + -ado (ar) / -ido (er/ir)",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "ind_futuro_perfecto",
formula: "haber (futuro) + past participle",
isCompound: true,
auxiliaryForms: ["habré", "habrás", "habrá", "habremos", "habréis", "habrán"],
arEndings: ["-ado", "-ado", "-ado", "-ado", "-ado", "-ado"],
erEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
irEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
stemRule: "Past participle: stem + -ado (ar) / -ido (er/ir)",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "ind_preterito_anterior",
formula: "haber (pretérito) + past participle",
isCompound: true,
auxiliaryForms: ["hube", "hubiste", "hubo", "hubimos", "hubisteis", "hubieron"],
arEndings: ["-ado", "-ado", "-ado", "-ado", "-ado", "-ado"],
erEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
irEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
stemRule: "Past participle: stem + -ado (ar) / -ido (er/ir)",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
// CONDITIONAL
TenseEndingTable(
id: "cond_presente",
formula: "infinitive + ending",
isCompound: false, auxiliaryForms: nil,
arEndings: ["-ía", "-ías", "-ía", "-íamos", "-íais", "-ían"],
erEndings: ["-ía", "-ías", "-ía", "-íamos", "-íais", "-ían"],
irEndings: ["-ía", "-ías", "-ía", "-íamos", "-íais", "-ían"],
stemRule: "Use full infinitive as stem",
arExample: ("hablar", "hablar"), erExample: ("comer", "comer"), irExample: ("vivir", "vivir")
),
TenseEndingTable(
id: "cond_perfecto",
formula: "haber (condicional) + past participle",
isCompound: true,
auxiliaryForms: ["habría", "habrías", "habría", "habríamos", "habríais", "habrían"],
arEndings: ["-ado", "-ado", "-ado", "-ado", "-ado", "-ado"],
erEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
irEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
stemRule: "Past participle: stem + -ado (ar) / -ido (er/ir)",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
// SUBJUNCTIVE
TenseEndingTable(
id: "subj_presente",
formula: "stem + ending (opposite vowel)",
isCompound: false, auxiliaryForms: nil,
arEndings: ["-e", "-es", "-e", "-emos", "-éis", "-en"],
erEndings: ["-a", "-as", "-a", "-amos", "-áis", "-an"],
irEndings: ["-a", "-as", "-a", "-amos", "-áis", "-an"],
stemRule: "Remove -ar/-er/-ir, use \"opposite\" vowel (ar→e, er/ir→a)",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "subj_imperfecto_1",
formula: "3rd person preterite stem + -ra endings",
isCompound: false, auxiliaryForms: nil,
arEndings: ["-ara", "-aras", "-ara", "-áramos", "-arais", "-aran"],
erEndings: ["-iera", "-ieras", "-iera", "-iéramos", "-ierais", "-ieran"],
irEndings: ["-iera", "-ieras", "-iera", "-iéramos", "-ierais", "-ieran"],
stemRule: "From 3rd person plural preterite, remove -ron",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "subj_imperfecto_2",
formula: "3rd person preterite stem + -se endings",
isCompound: false, auxiliaryForms: nil,
arEndings: ["-ase", "-ases", "-ase", "-ásemos", "-aseis", "-asen"],
erEndings: ["-iese", "-ieses", "-iese", "-iésemos", "-ieseis", "-iesen"],
irEndings: ["-iese", "-ieses", "-iese", "-iésemos", "-ieseis", "-iesen"],
stemRule: "From 3rd person plural preterite, remove -ron",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "subj_perfecto",
formula: "haber (subj. presente) + past participle",
isCompound: true,
auxiliaryForms: ["haya", "hayas", "haya", "hayamos", "hayáis", "hayan"],
arEndings: ["-ado", "-ado", "-ado", "-ado", "-ado", "-ado"],
erEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
irEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
stemRule: "Past participle: stem + -ado (ar) / -ido (er/ir)",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "subj_pluscuamperfecto_1",
formula: "haber (subj. imperfecto -ra) + past participle",
isCompound: true,
auxiliaryForms: ["hubiera", "hubieras", "hubiera", "hubiéramos", "hubierais", "hubieran"],
arEndings: ["-ado", "-ado", "-ado", "-ado", "-ado", "-ado"],
erEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
irEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
stemRule: "Past participle: stem + -ado (ar) / -ido (er/ir)",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "subj_pluscuamperfecto_2",
formula: "haber (subj. imperfecto -se) + past participle",
isCompound: true,
auxiliaryForms: ["hubiese", "hubieses", "hubiese", "hubiésemos", "hubieseis", "hubiesen"],
arEndings: ["-ado", "-ado", "-ado", "-ado", "-ado", "-ado"],
erEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
irEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
stemRule: "Past participle: stem + -ado (ar) / -ido (er/ir)",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "subj_futuro",
formula: "3rd person preterite stem + -re endings",
isCompound: false, auxiliaryForms: nil,
arEndings: ["-are", "-ares", "-are", "-áremos", "-areis", "-aren"],
erEndings: ["-iere", "-ieres", "-iere", "-iéremos", "-iereis", "-ieren"],
irEndings: ["-iere", "-ieres", "-iere", "-iéremos", "-iereis", "-ieren"],
stemRule: "Rarely used; from 3rd person plural preterite stem",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "subj_futuro_perfecto",
formula: "haber (subj. futuro) + past participle",
isCompound: true,
auxiliaryForms: ["hubiere", "hubieres", "hubiere", "hubiéremos", "hubiereis", "hubieren"],
arEndings: ["-ado", "-ado", "-ado", "-ado", "-ado", "-ado"],
erEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
irEndings: ["-ido", "-ido", "-ido", "-ido", "-ido", "-ido"],
stemRule: "Past participle: stem + -ado (ar) / -ido (er/ir)",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
// IMPERATIVE
TenseEndingTable(
id: "imp_afirmativo",
formula: "stem + ending",
isCompound: false, auxiliaryForms: nil,
arEndings: ["", "-a", "-e", "-emos", "-ad", "-en"],
erEndings: ["", "-e", "-a", "-amos", "-ed", "-an"],
irEndings: ["", "-e", "-a", "-amos", "-id", "-an"],
stemRule: "Remove -ar/-er/-ir; tú form = 3rd person singular present",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
TenseEndingTable(
id: "imp_negativo",
formula: "no + subjunctive present",
isCompound: false, auxiliaryForms: nil,
arEndings: ["", "no -es", "no -e", "no -emos", "no -éis", "no -en"],
erEndings: ["", "no -as", "no -a", "no -amos", "no -áis", "no -an"],
irEndings: ["", "no -as", "no -a", "no -amos", "no -áis", "no -an"],
stemRule: "Uses subjunctive present forms with \"no\" prefix",
arExample: ("hablar", "habl"), erExample: ("comer", "com"), irExample: ("vivir", "viv")
),
]
static func find(_ tenseId: String) -> TenseEndingTable? {
all.first { $0.id == tenseId }
}
}

View File

@@ -0,0 +1,15 @@
import SwiftData
import Foundation
@Model
final class TenseGuide {
var tenseId: String = ""
var title: String = ""
var body: String = ""
init(tenseId: String, title: String, body: String) {
self.tenseId = tenseId
self.title = title
self.body = body
}
}

View File

@@ -0,0 +1,78 @@
import Foundation
enum TenseID: String, CaseIterable, Codable, Sendable, Hashable {
case ind_presente
case ind_preterito
case ind_imperfecto
case ind_futuro
case ind_perfecto
case ind_pluscuamperfecto
case ind_futuro_perfecto
case ind_preterito_anterior
case cond_presente
case cond_perfecto
case subj_presente
case subj_imperfecto_1
case subj_imperfecto_2
case subj_perfecto
case subj_pluscuamperfecto_1
case subj_pluscuamperfecto_2
case subj_futuro
case subj_futuro_perfecto
case imp_afirmativo
case imp_negativo
static let defaultPractice: [TenseID] = [
.ind_presente,
.ind_preterito,
.ind_imperfecto,
.ind_futuro,
]
static let defaultPracticeIDs = defaultPractice.map(\.rawValue)
}
struct TenseInfo: Identifiable, Hashable, Sendable {
let id: String
let spanish: String
let english: String
let mood: String
let order: Int
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),
TenseInfo(id: TenseID.ind_imperfecto.rawValue, spanish: "Indicativo Imperfecto", english: "Imperfect", mood: "Indicative", order: 2),
TenseInfo(id: TenseID.ind_futuro.rawValue, spanish: "Indicativo Futuro", english: "Future", mood: "Indicative", order: 3),
TenseInfo(id: TenseID.ind_perfecto.rawValue, spanish: "Indicativo Perfecto", english: "Present Perfect", mood: "Indicative", order: 4),
TenseInfo(id: TenseID.ind_pluscuamperfecto.rawValue, spanish: "Indicativo Pluscuamperfecto", english: "Pluperfect", mood: "Indicative", order: 5),
TenseInfo(id: TenseID.ind_futuro_perfecto.rawValue, spanish: "Indicativo Futuro Perfecto", english: "Future Perfect", mood: "Indicative", order: 6),
TenseInfo(id: TenseID.ind_preterito_anterior.rawValue, spanish: "Indicativo Pretérito Anterior", english: "Preterite Perfect", mood: "Indicative", order: 7),
TenseInfo(id: TenseID.cond_presente.rawValue, spanish: "Condicional Presente", english: "Conditional", mood: "Conditional", order: 8),
TenseInfo(id: TenseID.cond_perfecto.rawValue, spanish: "Condicional Perfecto", english: "Conditional Perfect", mood: "Conditional", order: 9),
TenseInfo(id: TenseID.subj_presente.rawValue, spanish: "Subjuntivo Presente", english: "Present Subjunctive", mood: "Subjunctive", order: 10),
TenseInfo(id: TenseID.subj_imperfecto_1.rawValue, spanish: "Subjuntivo Imperfecto I", english: "Past Subjunctive (ra)", mood: "Subjunctive", order: 11),
TenseInfo(id: TenseID.subj_imperfecto_2.rawValue, spanish: "Subjuntivo Imperfecto II", english: "Past Subjunctive (se)", mood: "Subjunctive", order: 12),
TenseInfo(id: TenseID.subj_perfecto.rawValue, spanish: "Subjuntivo Perfecto", english: "Subjunctive Perfect", mood: "Subjunctive", order: 13),
TenseInfo(id: TenseID.subj_pluscuamperfecto_1.rawValue, spanish: "Subjuntivo Pluscuamperfecto I", english: "Subjunctive Pluperfect (ra)", mood: "Subjunctive", order: 14),
TenseInfo(id: TenseID.subj_pluscuamperfecto_2.rawValue, spanish: "Subjuntivo Pluscuamperfecto II", english: "Subjunctive Pluperfect (se)", mood: "Subjunctive", order: 15),
TenseInfo(id: TenseID.subj_futuro.rawValue, spanish: "Subjuntivo Futuro", english: "Subjunctive Future", mood: "Subjunctive", order: 16),
TenseInfo(id: TenseID.subj_futuro_perfecto.rawValue, spanish: "Subjuntivo Futuro Perfecto", english: "Subjunctive Future Perfect", mood: "Subjunctive", order: 17),
TenseInfo(id: TenseID.imp_afirmativo.rawValue, spanish: "Imperativo Afirmativo", english: "Imperative", mood: "Imperative", order: 18),
TenseInfo(id: TenseID.imp_negativo.rawValue, spanish: "Imperativo Negativo", english: "Negative Imperative", mood: "Imperative", order: 19),
]
static let persons = ["yo", "", "él/ella/Ud.", "nosotros", "vosotros", "ellos/ellas/Uds."]
static func byMood() -> [(String, [TenseInfo])] {
let grouped = Dictionary(grouping: all, by: \.mood)
return ["Indicative", "Conditional", "Subjunctive", "Imperative"].compactMap { mood in
guard let tenses = grouped[mood] else { return nil }
return (mood, tenses.sorted { $0.order < $1.order })
}
}
static func find(_ id: String) -> TenseInfo? {
all.first { $0.id == id }
}
}

View File

@@ -0,0 +1,74 @@
import SwiftData
import Foundation
struct MissedCourseItem: Codable, Hashable, Sendable {
let front: String
let back: String
}
@Model
final class TestResult {
var id: String = ""
var weekNumber: Int = 0
var quizType: String = ""
var totalQuestions: Int = 0
var correctCount: Int = 0
var dateTaken: Date = Date()
// Legacy CloudKit array-backed fields retained for migration compatibility.
var missedFronts: [String] = []
var missedBacks: [String] = []
var missedItemsBlob: String = ""
var scorePercent: Int {
guard totalQuestions > 0 else { return 0 }
return Int(round(Double(correctCount) / Double(totalQuestions) * 100))
}
init(weekNumber: Int, quizType: String, totalQuestions: Int, correctCount: Int, missedItems: [MissedCourseItem]) {
self.id = UUID().uuidString
self.weekNumber = weekNumber
self.quizType = quizType
self.totalQuestions = totalQuestions
self.correctCount = correctCount
self.dateTaken = Date()
self.missedItems = missedItems
}
convenience init(weekNumber: Int, quizType: String, totalQuestions: Int, correctCount: Int, missedFronts: [String], missedBacks: [String]) {
self.init(
weekNumber: weekNumber,
quizType: quizType,
totalQuestions: totalQuestions,
correctCount: correctCount,
missedItems: zip(missedFronts, missedBacks).map { MissedCourseItem(front: $0.0, back: $0.1) }
)
}
var missedItems: [MissedCourseItem] {
get {
guard !missedItemsBlob.isEmpty,
let data = missedItemsBlob.data(using: .utf8),
let values = try? JSONDecoder().decode([MissedCourseItem].self, from: data) else {
return zip(missedFronts, missedBacks).map { MissedCourseItem(front: $0.0, back: $0.1) }
}
return values
}
set {
guard let data = try? JSONEncoder().encode(newValue),
let string = String(data: data, encoding: .utf8) else {
missedItemsBlob = "[]"
return
}
missedItemsBlob = string
missedFronts = []
missedBacks = []
}
}
func migrateLegacyStorageIfNeeded() {
if missedItemsBlob.isEmpty && (!missedFronts.isEmpty || !missedBacks.isEmpty) {
missedItems = zip(missedFronts, missedBacks).map { MissedCourseItem(front: $0.0, back: $0.1) }
}
}
}

View File

@@ -0,0 +1,88 @@
import SwiftData
import Foundation
@Model
final class UserProgress {
var id: String = "main"
var dailyGoal: Int = 50
var todayCount: Int = 0
var todayDate: String = ""
var currentStreak: Int = 0
var longestStreak: Int = 0
var totalReviewed: Int = 0
var selectedLevel: String = "basic"
var showVosotros: Bool = true
var autoFillStem: Bool = false
// Legacy CloudKit array-backed fields retained for migration compatibility.
var enabledTenses: [String] = []
var unlockedBadges: [String] = []
var enabledTensesBlob: String = ""
var unlockedBadgesBlob: String = ""
init() {}
var selectedVerbLevel: VerbLevel {
get { VerbLevel(rawValue: selectedLevel) ?? .basic }
set { selectedLevel = newValue.rawValue }
}
var enabledTenseIDs: [String] {
get { decodeStringArray(from: enabledTensesBlob, fallback: enabledTenses) }
set {
enabledTensesBlob = Self.encodeStringArray(newValue)
enabledTenses = []
}
}
var unlockedBadgeIDs: [String] {
get { decodeStringArray(from: unlockedBadgesBlob, fallback: unlockedBadges) }
set {
unlockedBadgesBlob = Self.encodeStringArray(newValue)
unlockedBadges = []
}
}
func setTenseEnabled(_ tenseId: String, enabled: Bool) {
var values = Set(enabledTenseIDs)
if enabled {
values.insert(tenseId)
} else {
values.remove(tenseId)
}
enabledTenseIDs = values.sorted()
}
func unlockBadge(_ badgeId: String) {
var values = Set(unlockedBadgeIDs)
values.insert(badgeId)
unlockedBadgeIDs = values.sorted()
}
func migrateLegacyStorageIfNeeded() {
if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty {
enabledTenseIDs = enabledTenses
}
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
unlockedBadgeIDs = unlockedBadges
}
}
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
guard !blob.isEmpty,
let data = blob.data(using: .utf8),
let values = try? JSONDecoder().decode([String].self, from: data) else {
return fallback
}
return values
}
private static func encodeStringArray(_ values: [String]) -> String {
let normalized = Array(Set(values)).sorted()
guard let data = try? JSONEncoder().encode(normalized),
let string = String(data: data, encoding: .utf8) else {
return "[]"
}
return string
}
}

View File

@@ -0,0 +1,66 @@
import SwiftData
import Foundation
enum VerbLevel: String, CaseIterable, Sendable {
case basic
case elementary
case intermediate
case advanced
case expert
var displayName: String { rawValue.capitalized }
}
@Model
final class Verb {
var id: Int = 0
var infinitive: String = ""
var english: String = ""
var rank: Int = 0
var ending: String = ""
var reflexive: Int = 0
var level: String = ""
@Relationship(deleteRule: .cascade, inverse: \VerbForm.verb)
var forms: [VerbForm]?
init(id: Int, infinitive: String, english: String, rank: Int, ending: String, reflexive: Int, level: String) {
self.id = id
self.infinitive = infinitive
self.english = english
self.rank = rank
self.ending = ending
self.reflexive = reflexive
self.level = level
}
}
enum VerbLevelGroup: String, CaseIterable, Sendable {
case basic = "basic"
case elementary = "elementary"
case intermediate = "intermediate"
case advanced = "advanced"
case expert = "expert"
static func dataLevels(for selectedLevel: String) -> Set<String> {
switch selectedLevel {
case Self.basic.rawValue:
return ["basic"]
case Self.elementary.rawValue:
return ["elementary", "elementary_1", "elementary_2", "elementary_3"]
case Self.intermediate.rawValue:
return ["intermediate", "intermediate_1", "intermediate_2", "intermediate_3", "intermediate_4"]
case Self.advanced.rawValue:
return ["advanced"]
case Self.expert.rawValue:
return ["expert"]
default:
return [selectedLevel]
}
}
static func matches(_ dataLevel: String, selectedLevel: String) -> Bool {
dataLevels(for: selectedLevel).contains(dataLevel)
}
}

View File

@@ -0,0 +1,24 @@
import SwiftData
import Foundation
@Model
final class VerbForm {
var verbId: Int = 0
var tenseId: String = ""
var personIndex: Int = 0
var form: String = ""
var regularity: String = ""
var verb: Verb?
@Relationship(deleteRule: .cascade, inverse: \IrregularSpan.verbForm)
var spans: [IrregularSpan]?
init(verbId: Int, tenseId: String, personIndex: Int, form: String, regularity: String) {
self.verbId = verbId
self.tenseId = tenseId
self.personIndex = personIndex
self.form = form
self.regularity = regularity
}
}

View File

@@ -0,0 +1,141 @@
import Foundation
import SwiftData
enum Badge: String, CaseIterable, Identifiable, Sendable {
case firstReview
case streak3
case streak7
case streak30
case verbs25
case verbs100
case presentMaster = "present_master"
case preteriteMaster = "preterite_master"
case allTenses
case daily50
var id: String { rawValue }
var title: String {
switch self {
case .firstReview: "First Steps"
case .streak3: "Three in a Row"
case .streak7: "Weekly Warrior"
case .streak30: "Monthly Master"
case .verbs25: "Quarter Century"
case .verbs100: "Century Club"
case .presentMaster: "Present Tense Pro"
case .preteriteMaster: "Past Perfect"
case .allTenses: "Tense Champion"
case .daily50: "Daily Dedication"
}
}
var description: String {
switch self {
case .firstReview: "Complete your first review"
case .streak3: "3 day streak"
case .streak7: "7 day streak"
case .streak30: "30 day streak"
case .verbs25: "Review 25 unique verbs"
case .verbs100: "Review 100 unique verbs"
case .presentMaster: "Master all basic present tense verbs"
case .preteriteMaster: "Master all basic preterite verbs"
case .allTenses: "Practice all 20 tenses"
case .daily50: "Complete 50 reviews in one day"
}
}
var icon: String {
switch self {
case .firstReview: "star.fill"
case .streak3: "flame.fill"
case .streak7: "flame.fill"
case .streak30: "flame.circle.fill"
case .verbs25: "25.square.fill"
case .verbs100: "100.circle.fill"
case .presentMaster: "checkmark.seal.fill"
case .preteriteMaster: "checkmark.seal.fill"
case .allTenses: "trophy.fill"
case .daily50: "bolt.fill"
}
}
}
struct AchievementService: Sendable {
/// Check all badges and return any newly earned ones.
static func checkAchievements(progress: UserProgress, context: ModelContext) -> [Badge] {
var newBadges: [Badge] = []
for badge in Badge.allCases {
guard !progress.unlockedBadgeIDs.contains(badge.rawValue) else { continue }
let earned: Bool
switch badge {
case .firstReview:
earned = progress.totalReviewed >= 1
case .streak3:
earned = progress.currentStreak >= 3
case .streak7:
earned = progress.currentStreak >= 7
case .streak30:
earned = progress.currentStreak >= 30
case .verbs25:
earned = uniqueVerbsReviewed(context: context) >= 25
case .verbs100:
earned = uniqueVerbsReviewed(context: context) >= 100
case .presentMaster:
earned = hasMasteredTense(TenseID.ind_presente.rawValue, level: VerbLevel.basic.rawValue, context: context)
case .preteriteMaster:
earned = hasMasteredTense(TenseID.ind_preterito.rawValue, level: VerbLevel.basic.rawValue, context: context)
case .allTenses:
earned = hasUsedAllTenses(context: context)
case .daily50:
earned = progress.todayCount >= 50
}
if earned {
progress.unlockBadge(badge.rawValue)
newBadges.append(badge)
}
}
return newBadges
}
// MARK: - Helpers
private static func uniqueVerbsReviewed(context: ModelContext) -> Int {
let descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate { $0.repetitions > 0 }
)
let cards = (try? context.fetch(descriptor)) ?? []
let uniqueVerbs = Set(cards.map(\.verbId))
return uniqueVerbs.count
}
private static func hasMasteredTense(_ tenseId: String, level: String, context: ModelContext) -> Bool {
let verbIds = Set(ReferenceStore(context: context).fetchVerbs(selectedLevel: level).map(\.id))
guard !verbIds.isEmpty else { return false }
let cardDescriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { card in
card.tenseId == tenseId && card.interval >= 21
}
)
let cards = (try? context.fetch(cardDescriptor)) ?? []
let masteredVerbIds = Set(cards.map(\.verbId))
return verbIds.isSubset(of: masteredVerbIds)
}
private static func hasUsedAllTenses(context: ModelContext) -> Bool {
let descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate { $0.repetitions > 0 }
)
let cards = (try? context.fetch(descriptor)) ?? []
let usedTenses = Set(cards.map(\.tenseId))
let allTenseIds = Set(TenseInfo.all.map(\.id))
return allTenseIds.isSubset(of: usedTenses)
}
}

View File

@@ -0,0 +1,44 @@
import Foundation
import SharedModels
import SwiftData
struct CourseReviewStore {
let context: ModelContext
@discardableResult
func fetchOrCreateReviewCard(for card: VocabCard) -> CourseReviewCard {
let reviewKey = CourseCardStore.reviewKey(for: card)
let descriptor = FetchDescriptor<CourseReviewCard>(
predicate: #Predicate<CourseReviewCard> { $0.id == reviewKey }
)
if let existing = (try? context.fetch(descriptor))?.first {
return existing
}
let reviewCard = CourseReviewCard(
id: reviewKey,
deckId: card.deckId,
front: card.front,
back: card.back
)
context.insert(reviewCard)
return reviewCard
}
func rate(card: VocabCard, quality: ReviewQuality) {
let reviewCard = fetchOrCreateReviewCard(for: card)
let result = SRSEngine.review(
quality: quality,
currentEase: reviewCard.easeFactor,
currentInterval: reviewCard.interval,
currentReps: reviewCard.repetitions
)
reviewCard.easeFactor = result.easeFactor
reviewCard.interval = result.interval
reviewCard.repetitions = result.repetitions
reviewCard.dueDate = SRSEngine.nextDueDate(interval: result.interval)
reviewCard.lastReviewDate = Date()
try? context.save()
}
}

View File

@@ -0,0 +1,292 @@
import SwiftData
import SharedModels
import Foundation
actor DataLoader {
static func seedIfNeeded(container: ModelContainer) async {
let context = ModelContext(container)
var descriptor = FetchDescriptor<Verb>()
descriptor.fetchLimit = 1
let count = (try? context.fetchCount(descriptor)) ?? 0
if count > 0 { return }
print("Seeding database...")
// Try direct bundle lookup first, then subdirectory
let url = Bundle.main.url(forResource: "conjuga_data", withExtension: "json")
?? Bundle.main.url(forResource: "conjuga_data", withExtension: "json", subdirectory: "Resources")
?? Bundle.main.bundleURL.appendingPathComponent("Resources/conjuga_data.json")
guard let data = try? Data(contentsOf: url) else {
print("ERROR: Could not load conjuga_data.json from bundle at \(url)")
return
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("ERROR: Could not parse conjuga_data")
return
}
// Seed tense guides
if let guides = json["tenseGuides"] as? [[String: Any]] {
for g in guides {
guard let tenseId = g["tenseId"] as? String,
let title = g["title"] as? String,
let body = g["body"] as? String else { continue }
let guide = TenseGuide(tenseId: tenseId, title: title, body: body)
context.insert(guide)
}
}
// Seed verbs
var verbMap: [Int: Verb] = [:]
if let verbs = json["verbs"] as? [[String: Any]] {
for v in verbs {
guard let id = v["id"] as? Int,
let infinitive = v["infinitive"] as? String,
let english = v["english"] as? String,
let rank = v["rank"] as? Int,
let ending = v["ending"] as? String,
let reflexive = v["reflexive"] as? Int,
let level = v["level"] as? String else { continue }
let verb = Verb(id: id, infinitive: infinitive, english: english, rank: rank, ending: ending, reflexive: reflexive, level: level)
context.insert(verb)
verbMap[id] = verb
}
print("Inserted \(verbs.count) verbs")
}
try? context.save()
// Seed verb forms bulk insert, no relationship assignment (use verbId for queries)
let chunkSize = 20000
if let forms = json["verbForms"] as? [[String: Any]] {
for i in stride(from: 0, to: forms.count, by: chunkSize) {
autoreleasepool {
let end = min(i + chunkSize, forms.count)
for j in i..<end {
let f = forms[j]
guard let verbId = f["verbId"] as? Int,
let tenseId = f["tenseId"] as? String,
let personIndex = f["personIndex"] as? Int,
let form = f["form"] as? String,
let regularity = f["regularity"] as? String else { continue }
let vf = VerbForm(verbId: verbId, tenseId: tenseId, personIndex: personIndex, form: form, regularity: regularity)
context.insert(vf)
}
try? context.save()
}
}
print("Inserted \(forms.count) verb forms")
}
// Seed irregular spans bulk insert
if let spans = json["irregularSpans"] as? [[String: Any]] {
for i in stride(from: 0, to: spans.count, by: chunkSize) {
autoreleasepool {
let end = min(i + chunkSize, spans.count)
for j in i..<end {
let s = spans[j]
guard let verbId = s["verbId"] as? Int,
let tenseId = s["tenseId"] as? String,
let personIndex = s["personIndex"] as? Int,
let spanType = s["type"] as? Int,
let pattern = s["pattern"] as? Int,
let start = s["start"] as? Int,
let end = s["end"] as? Int else { continue }
let span = IrregularSpan(verbId: verbId, tenseId: tenseId, personIndex: personIndex, spanType: spanType, pattern: pattern, start: start, end: end)
context.insert(span)
}
try? context.save()
}
}
print("Inserted \(spans.count) irregular spans")
}
try? context.save()
print("Verb seeding complete")
// Seed course data
seedCourseData(context: context)
}
/// 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 }
print("Course data version outdated — re-seeding...")
let context = ModelContext(container)
// Delete existing course data
try? context.delete(model: VocabCard.self)
try? context.delete(model: CourseDeck.self)
try? context.save()
// Re-seed
seedCourseData(context: context)
shared.set(currentVersion, forKey: key)
print("Course data re-seeded to version \(currentVersion)")
}
static func migrateCourseProgressIfNeeded(container: ModelContainer) async {
let migrationVersion = 1
let key = "courseProgressMigrationVersion"
let shared = UserDefaults.standard
if shared.integer(forKey: key) >= migrationVersion { return }
let context = ModelContext(container)
let descriptor = FetchDescriptor<VocabCard>()
let allCards = (try? context.fetch(descriptor)) ?? []
var migratedCount = 0
for card in allCards where hasLegacyCourseProgress(card) {
let reviewKey = CourseCardStore.reviewKey(for: card)
let reviewCard = findOrCreateCourseReviewCard(
id: reviewKey,
deckId: card.deckId,
front: card.front,
back: card.back,
context: context
)
if let reviewDate = reviewCard.lastReviewDate,
let legacyDate = card.lastReviewDate,
reviewDate >= legacyDate {
continue
}
reviewCard.easeFactor = card.easeFactor
reviewCard.interval = card.interval
reviewCard.repetitions = card.repetitions
reviewCard.dueDate = card.dueDate
reviewCard.lastReviewDate = card.lastReviewDate
migratedCount += 1
}
if migratedCount > 0 {
try? context.save()
print("Migrated \(migratedCount) course progress cards to cloud store")
}
shared.set(migrationVersion, forKey: key)
}
private static func seedCourseData(context: ModelContext) {
let url = Bundle.main.url(forResource: "course_data", withExtension: "json")
?? Bundle.main.bundleURL.appendingPathComponent("course_data.json")
guard let data = try? Data(contentsOf: url) else {
print("No course_data.json found — skipping course seeding")
return
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("ERROR: Could not parse course_data.json")
return
}
// Support both formats: {"courses": [...]} (new) and {"course": "...", "weeks": [...]} (old)
var courseList: [[String: Any]] = []
if let courses = json["courses"] as? [[String: Any]] {
courseList = courses
} else if json["weeks"] != nil {
courseList = [json]
}
var deckCount = 0
var cardCount = 0
for courseData in courseList {
guard let weeks = courseData["weeks"] as? [[String: Any]],
let courseName = courseData["course"] as? String else { continue }
let courseSlug = courseName.lowercased()
.replacingOccurrences(of: " ", with: "-")
.replacingOccurrences(of: "|", with: "")
for weekData in weeks {
guard let weekNum = weekData["week"] as? Int,
let decks = weekData["decks"] as? [[String: Any]] else { continue }
for (deckIndex, deckData) in decks.enumerated() {
guard let title = deckData["title"] as? String,
let cards = deckData["cards"] as? [Any] else { continue }
let isReversed = (deckData["isReversed"] as? Bool) ?? false
let deckId = "\(courseSlug)_w\(weekNum)_\(deckIndex)_\(isReversed ? "rev" : "fwd")"
let deck = CourseDeck(
id: deckId,
weekNumber: weekNum,
title: title,
cardCount: cards.count,
courseName: courseName,
isReversed: isReversed
)
context.insert(deck)
deckCount += 1
for rawCard in cards {
guard let cardDict = rawCard as? [String: Any],
let front = cardDict["front"] as? String,
let back = cardDict["back"] as? String else { continue }
// Parse example sentences
var exES: [String] = []
var exEN: [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) }
}
}
let card = VocabCard(front: front, back: back, deckId: deckId, examplesES: exES, examplesEN: exEN)
card.deck = deck
context.insert(card)
cardCount += 1
}
}
try? context.save()
}
}
print("Course seeding complete: \(deckCount) decks, \(cardCount) cards")
}
private static func hasLegacyCourseProgress(_ card: VocabCard) -> Bool {
card.repetitions > 0 ||
card.interval > 0 ||
abs(card.easeFactor - 2.5) > 0.0001 ||
card.lastReviewDate != nil
}
private static func findOrCreateCourseReviewCard(
id: String,
deckId: String,
front: String,
back: String,
context: ModelContext
) -> CourseReviewCard {
let descriptor = FetchDescriptor<CourseReviewCard>(
predicate: #Predicate<CourseReviewCard> { $0.id == id }
)
if let existing = (try? context.fetch(descriptor))?.first {
return existing
}
let reviewCard = CourseReviewCard(id: id, deckId: deckId, front: front, back: back)
context.insert(reviewCard)
return reviewCard
}
}

View File

@@ -0,0 +1,119 @@
import Foundation
import PencilKit
import Vision
import UIKit
import os
private let logger = Logger(subsystem: "com.conjuga.app", category: "HandwritingRecognizer")
/// Result from handwriting recognition including confidence.
struct RecognitionResult: Sendable {
let text: String
let confidence: Float // 0.0 to 1.0
}
/// Recognizes handwritten text from a PKDrawing using Vision framework.
struct HandwritingRecognizer: Sendable {
/// Minimum confidence to accept a recognition result.
static let minimumConfidence: Float = 0.3
/// Recognize text from a PencilKit drawing.
/// Returns the recognized text and confidence score.
static func recognize(drawing: PKDrawing) async -> RecognitionResult {
guard !drawing.strokes.isEmpty else { return RecognitionResult(text: "", confidence: 0) }
let bounds = drawing.bounds
guard bounds.width > 0 && bounds.height > 0 else { return RecognitionResult(text: "", confidence: 0) }
// Render with generous padding and white background for Vision
let padding: CGFloat = 60
let renderBounds = bounds.insetBy(dx: -padding, dy: -padding)
// Enforce minimum size Vision struggles with tiny images
let minDimension: CGFloat = 300
let width = max(renderBounds.width, minDimension)
let height = max(renderBounds.height, minDimension)
let size = CGSize(width: width, height: height)
let scale: CGFloat = 3.0
let scaledSize = CGSize(width: size.width * scale, height: size.height * scale)
let renderer = UIGraphicsImageRenderer(size: scaledSize)
let image = renderer.image { ctx in
UIColor.white.setFill()
ctx.fill(CGRect(origin: .zero, size: scaledSize))
// Center the drawing in the canvas
let drawingImage = drawing.image(from: renderBounds, scale: scale)
let xOffset = (scaledSize.width - renderBounds.width * scale) / 2
let yOffset = (scaledSize.height - renderBounds.height * scale) / 2
drawingImage.draw(at: CGPoint(x: xOffset, y: yOffset))
}
guard let cgImage = image.cgImage else {
logger.error("Failed to create CGImage from drawing")
return RecognitionResult(text: "", confidence: 0)
}
logger.info("Rendered image: \(Int(scaledSize.width))x\(Int(scaledSize.height)), strokes: \(drawing.strokes.count), drawingBounds: \(Int(bounds.width))x\(Int(bounds.height))")
return await withCheckedContinuation { continuation in
let request = VNRecognizeTextRequest { request, error in
guard error == nil,
let observations = request.results as? [VNRecognizedTextObservation] else {
continuation.resume(returning: RecognitionResult(text: "", confidence: 0))
return
}
logger.info("Vision returned \(observations.count) observations")
var allText: [String] = []
var totalConfidence: Float = 0
var count: Float = 0
for (i, observation) in observations.enumerated() {
let candidates = observation.topCandidates(3)
for (j, candidate) in candidates.enumerated() {
logger.info(" Observation[\(i)] candidate[\(j)]: \"\(candidate.string)\" confidence=\(candidate.confidence)")
}
if let best = candidates.first {
allText.append(best.string)
totalConfidence += best.confidence
count += 1
}
}
let text = allText.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
let avgConfidence = count > 0 ? totalConfidence / count : 0
logger.info("Final recognized: \"\(text)\" avgConfidence=\(avgConfidence)")
continuation.resume(returning: RecognitionResult(text: text, confidence: avgConfidence))
}
request.recognitionLanguages = ["es", "en"]
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(returning: RecognitionResult(text: "", confidence: 0))
}
}
}
/// Compare recognized text against expected answer.
/// Case-insensitive, accent-sensitive comparison with Spanish locale.
/// Also requires minimum confidence threshold.
static func checkAnswer(result: RecognitionResult, expected: String) -> Bool {
let trimmedRecognized = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedExpected = expected.trimmingCharacters(in: .whitespacesAndNewlines)
let match = !trimmedRecognized.isEmpty
&& result.confidence >= minimumConfidence
&& trimmedRecognized.compare(trimmedExpected, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
logger.info("checkAnswer: recognized=\"\(trimmedRecognized)\" expected=\"\(trimmedExpected)\" confidence=\(result.confidence) minConfidence=\(minimumConfidence) match=\(match)")
return match
}
}

View File

@@ -0,0 +1,232 @@
import Foundation
import SwiftData
struct PracticeSettings: Sendable {
let selectedLevel: String
let enabledTenses: Set<String>
let showVosotros: Bool
init(progress: UserProgress?) {
let resolved = progress?.enabledTenseIDs ?? []
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
self.enabledTenses = Set(resolved)
self.showVosotros = progress?.showVosotros ?? true
}
var selectionTenseIDs: [String] {
enabledTenses.isEmpty ? TenseID.defaultPracticeIDs : Array(enabledTenses)
}
}
struct PracticeCardLoad {
let verb: Verb
let form: VerbForm
let spans: [IrregularSpan]
let tenseInfo: TenseInfo?
let person: String
}
struct FullTablePrompt {
let verb: Verb
let tenseInfo: TenseInfo
let forms: [VerbForm]
}
struct PracticeSessionService {
let context: ModelContext
private let referenceStore: ReferenceStore
init(context: ModelContext) {
self.context = context
self.referenceStore = ReferenceStore(context: context)
}
func settings() -> PracticeSettings {
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: context))
}
func nextCard(for focusMode: FocusMode) -> PracticeCardLoad? {
switch focusMode {
case .weakVerbs:
if let form = pickWeakForm() {
return loadCard(from: form)
}
case .irregularity(let filter):
if let form = pickIrregularForm(filter: filter) {
return loadCard(from: form)
}
case .none:
break
}
if let dueCard = fetchDueCard() {
return loadCard(from: dueCard)
}
if let form = pickRandomForm() {
return loadCard(from: form)
}
return nil
}
func randomFullTablePrompt() -> FullTablePrompt? {
let settings = settings()
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
guard !verbs.isEmpty else { return nil }
for _ in 0..<40 {
guard let verb = verbs.randomElement(),
let tenseId = settings.selectionTenseIDs.randomElement(),
let tenseInfo = TenseInfo.find(tenseId) else { continue }
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
if forms.isEmpty { continue }
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms)
}
return nil
}
func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
ReviewStore.recordReview(
verbId: verbId,
tenseId: tenseId,
personIndex: personIndex,
quality: quality,
context: context
)
}
func recordFullTableReview(verbId: Int, tenseId: String, results: [Int: Bool]) -> [Badge] {
ReviewStore.recordFullTableReview(
verbId: verbId,
tenseId: tenseId,
results: results,
context: context
)
}
func loadCard(from reviewCard: ReviewCard) -> PracticeCardLoad? {
guard let verb = referenceStore.fetchVerb(id: reviewCard.verbId),
let form = referenceStore.fetchForm(
verbId: reviewCard.verbId,
tenseId: reviewCard.tenseId,
personIndex: reviewCard.personIndex
) else { return nil }
return buildCardLoad(verb: verb, form: form)
}
func loadCard(from form: VerbForm) -> PracticeCardLoad? {
guard let verb = referenceStore.fetchVerb(id: form.verbId) else { return nil }
return buildCardLoad(verb: verb, form: form)
}
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
let spans = referenceStore.fetchSpans(
verbId: form.verbId,
tenseId: form.tenseId,
personIndex: form.personIndex
)
let person = TenseInfo.persons.indices.contains(form.personIndex)
? TenseInfo.persons[form.personIndex]
: ""
return PracticeCardLoad(
verb: verb,
form: form,
spans: spans,
tenseInfo: TenseInfo.find(form.tenseId),
person: person
)
}
private func fetchDueCard() -> ReviewCard? {
let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
let now = Date()
var descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
sortBy: [SortDescriptor(\ReviewCard.dueDate)]
)
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
let cards = (try? context.fetch(descriptor)) ?? []
return cards.first { card in
allowedVerbIds.contains(card.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
(settings.showVosotros || card.personIndex != 4)
}
}
private func pickWeakForm() -> VerbForm? {
let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
let descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
sortBy: [SortDescriptor(\ReviewCard.easeFactor)]
)
let cards = ((try? context.fetch(descriptor)) ?? []).filter { card in
allowedVerbIds.contains(card.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
(settings.showVosotros || card.personIndex != 4)
}
guard let card = cards.randomElement() else { return nil }
return referenceStore.fetchForm(
verbId: card.verbId,
tenseId: card.tenseId,
personIndex: card.personIndex
)
}
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
let typeRange: ClosedRange<Int>
switch filter {
case .spelling:
typeRange = 100...199
case .stemChange:
typeRange = 200...299
case .uniqueIrregular:
typeRange = 300...399
}
var descriptor = FetchDescriptor<IrregularSpan>(
predicate: #Predicate<IrregularSpan> { span in
span.spanType >= typeRange.lowerBound &&
span.spanType <= typeRange.upperBound
}
)
descriptor.fetchLimit = 500
let spans = ((try? context.fetch(descriptor)) ?? []).filter { span in
allowedVerbIds.contains(span.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(span.tenseId)) &&
(settings.showVosotros || span.personIndex != 4)
}
guard let span = spans.randomElement() else { return nil }
return referenceStore.fetchForm(
verbId: span.verbId,
tenseId: span.tenseId,
personIndex: span.personIndex
)
}
private func pickRandomForm() -> VerbForm? {
let settings = settings()
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
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(form.tenseId)) &&
(settings.showVosotros || form.personIndex != 4)
}
return forms.randomElement()
}
}

View File

@@ -0,0 +1,74 @@
import Foundation
import SwiftData
struct ReferenceStore {
let context: ModelContext
func fetchGuides() -> [TenseGuide] {
let descriptor = FetchDescriptor<TenseGuide>(sortBy: [SortDescriptor(\TenseGuide.tenseId)])
return (try? context.fetch(descriptor)) ?? []
}
func fetchGuideMap() -> [String: TenseGuide] {
Dictionary(uniqueKeysWithValues: fetchGuides().map { ($0.tenseId, $0) })
}
func fetchVerbs() -> [Verb] {
let descriptor = FetchDescriptor<Verb>(sortBy: [SortDescriptor(\Verb.infinitive)])
return (try? context.fetch(descriptor)) ?? []
}
func fetchVerbs(selectedLevel: String) -> [Verb] {
fetchVerbs().filter { VerbLevelGroup.matches($0.level, selectedLevel: selectedLevel) }
}
func allowedVerbIDs(selectedLevel: String) -> Set<Int> {
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
}
func fetchVerb(id: Int) -> Verb? {
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
return (try? context.fetch(descriptor))?.first
}
func fetchVerbForms(verbId: Int) -> [VerbForm] {
let descriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { $0.verbId == verbId },
sortBy: [SortDescriptor(\VerbForm.tenseId), SortDescriptor(\VerbForm.personIndex)]
)
return (try? context.fetch(descriptor)) ?? []
}
func fetchForms(verbId: Int, tenseId: String) -> [VerbForm] {
let descriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { form in
form.verbId == verbId && form.tenseId == tenseId
},
sortBy: [SortDescriptor(\VerbForm.personIndex)]
)
return (try? context.fetch(descriptor)) ?? []
}
func fetchForm(verbId: Int, tenseId: String, personIndex: Int) -> VerbForm? {
let descriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { form in
form.verbId == verbId &&
form.tenseId == tenseId &&
form.personIndex == personIndex
}
)
return (try? context.fetch(descriptor))?.first
}
func fetchSpans(verbId: Int, tenseId: String, personIndex: Int) -> [IrregularSpan] {
let descriptor = FetchDescriptor<IrregularSpan>(
predicate: #Predicate<IrregularSpan> { span in
span.verbId == verbId &&
span.tenseId == tenseId &&
span.personIndex == personIndex
},
sortBy: [SortDescriptor(\IrregularSpan.start)]
)
return (try? context.fetch(descriptor)) ?? []
}
}

View File

@@ -0,0 +1,200 @@
import Foundation
import SwiftData
struct ReviewStore {
static let progressID = "main"
static func defaultEnabledTenses() -> [String] {
TenseID.defaultPracticeIDs
}
static func fetchUserProgress(context: ModelContext) -> UserProgress? {
let descriptor = FetchDescriptor<UserProgress>(
predicate: #Predicate<UserProgress> { $0.id == progressID }
)
if let progress = (try? context.fetch(descriptor))?.first {
progress.migrateLegacyStorageIfNeeded()
return progress
}
let fallback = (try? context.fetch(FetchDescriptor<UserProgress>()))?.first
fallback?.migrateLegacyStorageIfNeeded()
return fallback
}
@discardableResult
static func fetchOrCreateUserProgress(context: ModelContext) -> UserProgress {
if let progress = fetchUserProgress(context: context) {
progress.id = progressID
if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = defaultEnabledTenses()
}
return progress
}
let progress = UserProgress()
progress.id = progressID
progress.enabledTenseIDs = defaultEnabledTenses()
context.insert(progress)
return progress
}
@discardableResult
static func fetchOrCreateReviewCard(
verbId: Int,
tenseId: String,
personIndex: Int,
context: ModelContext
) -> ReviewCard {
let key = ReviewCard.makeKey(verbId: verbId, tenseId: tenseId, personIndex: personIndex)
let keyedDescriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.key == key }
)
if let existing = (try? context.fetch(keyedDescriptor))?.first {
existing.refreshIdentityIfNeeded()
return existing
}
let legacyDescriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { card in
card.verbId == verbId &&
card.tenseId == tenseId &&
card.personIndex == personIndex
}
)
if let existing = (try? context.fetch(legacyDescriptor))?.first {
existing.refreshIdentityIfNeeded()
return existing
}
let newCard = ReviewCard(verbId: verbId, tenseId: tenseId, personIndex: personIndex)
context.insert(newCard)
return newCard
}
@discardableResult
static func updateProgress(
reviewIncrement: Int,
correctIncrement: Int,
context: ModelContext,
date: Date = Date()
) -> UserProgress {
let progress = fetchOrCreateUserProgress(context: context)
let todayString = DailyLog.dateString(from: date)
if progress.todayDate != todayString {
if !progress.todayDate.isEmpty {
if isConsecutiveDay(previous: progress.todayDate, current: todayString) {
progress.currentStreak += 1
} else {
progress.currentStreak = 1
}
} else {
progress.currentStreak = 1
}
progress.todayDate = todayString
progress.todayCount = 0
}
progress.todayCount += reviewIncrement
progress.totalReviewed += reviewIncrement
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
log.reviewCount += reviewIncrement
log.correctCount += correctIncrement
return progress
}
static func recordReview(
verbId: Int,
tenseId: String,
personIndex: Int,
quality: ReviewQuality,
context: ModelContext
) -> [Badge] {
let card = fetchOrCreateReviewCard(
verbId: verbId,
tenseId: tenseId,
personIndex: personIndex,
context: context
)
applyReview(quality: quality, to: card)
let progress = updateProgress(
reviewIncrement: 1,
correctIncrement: quality.rawValue >= 3 ? 1 : 0,
context: context
)
let badges = AchievementService.checkAchievements(progress: progress, context: context)
try? context.save()
return badges
}
static func recordFullTableReview(
verbId: Int,
tenseId: String,
results: [Int: Bool],
context: ModelContext
) -> [Badge] {
for (personIndex, isCorrect) in results {
let card = fetchOrCreateReviewCard(
verbId: verbId,
tenseId: tenseId,
personIndex: personIndex,
context: context
)
applyReview(quality: isCorrect ? .good : .again, to: card)
}
let allCorrect = results.values.allSatisfy { $0 }
let progress = updateProgress(
reviewIncrement: 1,
correctIncrement: allCorrect ? 1 : 0,
context: context
)
let badges = AchievementService.checkAchievements(progress: progress, context: context)
try? context.save()
return badges
}
static func fetchOrCreateDailyLog(dateString: String, context: ModelContext) -> DailyLog {
let descriptor = FetchDescriptor<DailyLog>(
predicate: #Predicate<DailyLog> { $0.id == dateString || $0.dateString == dateString }
)
if let existing = (try? context.fetch(descriptor))?.first {
existing.refreshIdentityIfNeeded()
return existing
}
let log = DailyLog(dateString: dateString)
context.insert(log)
return log
}
static func applyReview(quality: ReviewQuality, to card: ReviewCard) {
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()
card.refreshIdentityIfNeeded()
}
private static func isConsecutiveDay(previous: String, current: String) -> Bool {
guard let prevDate = DailyLog.date(from: previous),
let currDate = DailyLog.date(from: current) else { return false }
let diff = Calendar.current.dateComponents([.day], from: prevDate, to: currDate)
return diff.day == 1
}
}

View File

@@ -0,0 +1,64 @@
import Foundation
enum ReviewQuality: Int, Sendable {
case again = 0 // Complete failure
case hard = 1 // Significant difficulty
case good = 3 // Correct with some effort
case easy = 5 // Perfect recall
}
struct SRSEngine: Sendable {
/// Apply SM-2 algorithm. Returns updated (easeFactor, interval, repetitions).
static func review(
quality: ReviewQuality,
currentEase: Double,
currentInterval: Int,
currentReps: Int
) -> (easeFactor: Double, interval: Int, repetitions: Int) {
let q = Double(quality.rawValue)
// Calculate new ease factor
var newEase = currentEase + (0.1 - (5.0 - q) * (0.08 + (5.0 - q) * 0.02))
newEase = max(1.3, newEase)
var newInterval: Int
var newReps: Int
if quality.rawValue < 2 {
// Failed reset
newReps = 0
newInterval = 0
} else {
newReps = currentReps + 1
switch newReps {
case 1: newInterval = 1
case 2: newInterval = 6
default: newInterval = Int(round(Double(currentInterval) * newEase))
}
}
return (newEase, newInterval, newReps)
}
/// Calculate next due date from today
static func nextDueDate(interval: Int) -> Date {
Calendar.current.date(byAdding: .day, value: max(interval, 0), to: Date()) ?? Date()
}
/// Check if a card is due for review
static func isDue(_ card: some ReviewCardProtocol) -> Bool {
card.dueDate <= Date()
}
}
// Protocol so SRSEngine doesn't depend on SwiftData
protocol ReviewCardProtocol {
var dueDate: Date { get }
var easeFactor: Double { get }
var interval: Int { get }
var repetitions: Int { get }
}
// MARK: - ReviewCard conformance
extension ReviewCard: ReviewCardProtocol {}

View File

@@ -0,0 +1,41 @@
import AVFoundation
@Observable
@MainActor
final class SpeechService {
private let synthesizer = AVSpeechSynthesizer()
private let spanishVoice: AVSpeechSynthesisVoice?
private var audioSessionConfigured = false
init() {
spanishVoice = AVSpeechSynthesisVoice(language: "es-ES")
}
func speak(_ text: String) {
configureAudioSession()
synthesizer.stopSpeaking(at: .immediate)
let utterance = AVSpeechUtterance(string: text)
utterance.voice = spanishVoice
utterance.rate = 0.45
utterance.pitchMultiplier = 1.0
utterance.preUtteranceDelay = 0
utterance.postUtteranceDelay = 0
synthesizer.speak(utterance)
}
func stop() {
synthesizer.stopSpeaking(at: .immediate)
}
private func configureAudioSession() {
guard !audioSessionConfigured else { return }
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default, options: [.duckOthers])
try session.setActive(true)
audioSessionConfigured = true
} catch {
print("Failed to configure audio session: \(error)")
}
}
}

View File

@@ -0,0 +1,175 @@
import Foundation
import SharedModels
import SwiftData
enum StartupCoordinator {
@MainActor
static func run(container: ModelContainer) async {
await DataLoader.seedIfNeeded(container: container)
await DataLoader.refreshCourseDataIfNeeded(container: container)
await DataLoader.migrateCourseProgressIfNeeded(container: container)
let context = container.mainContext
let progress = ReviewStore.fetchOrCreateUserProgress(context: context)
progress.migrateLegacyStorageIfNeeded()
if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
}
migrateLegacyPayloads(context: context)
repairIdentityFields(context: context)
dedupeCloudState(context: context)
try? context.save()
}
private static func migrateLegacyPayloads(context: ModelContext) {
let progressList = (try? context.fetch(FetchDescriptor<UserProgress>())) ?? []
for progress in progressList {
progress.migrateLegacyStorageIfNeeded()
if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
}
if progress.id.isEmpty {
progress.id = ReviewStore.progressID
}
}
let testResults = (try? context.fetch(FetchDescriptor<TestResult>())) ?? []
for result in testResults {
result.migrateLegacyStorageIfNeeded()
}
}
private static func repairIdentityFields(context: ModelContext) {
let reviewCards = (try? context.fetch(FetchDescriptor<ReviewCard>())) ?? []
for card in reviewCards {
card.refreshIdentityIfNeeded()
}
let dailyLogs = (try? context.fetch(FetchDescriptor<DailyLog>())) ?? []
for log in dailyLogs {
log.refreshIdentityIfNeeded()
}
}
private static func dedupeCloudState(context: ModelContext) {
dedupeUserProgress(context: context)
dedupeReviewCards(context: context)
dedupeCourseReviewCards(context: context)
dedupeDailyLogs(context: context)
}
private static func dedupeUserProgress(context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<UserProgress>())) ?? []
guard all.count > 1 else { return }
let ranked = all.sorted { score($0) > score($1) }
guard let canonical = ranked.first else { return }
canonical.id = ReviewStore.progressID
canonical.migrateLegacyStorageIfNeeded()
let mergedTenses = Set(all.flatMap(\.enabledTenseIDs))
let mergedBadges = Set(all.flatMap(\.unlockedBadgeIDs))
canonical.enabledTenseIDs = mergedTenses.isEmpty ? ReviewStore.defaultEnabledTenses() : mergedTenses.sorted()
canonical.unlockedBadgeIDs = mergedBadges.sorted()
canonical.totalReviewed = all.map(\.totalReviewed).max() ?? canonical.totalReviewed
canonical.longestStreak = all.map(\.longestStreak).max() ?? canonical.longestStreak
canonical.currentStreak = all.map(\.currentStreak).max() ?? canonical.currentStreak
canonical.dailyGoal = ranked.first(where: { $0.dailyGoal != 50 })?.dailyGoal ?? canonical.dailyGoal
canonical.selectedLevel = ranked.first(where: { $0.selectedLevel != VerbLevel.basic.rawValue })?.selectedLevel ?? canonical.selectedLevel
canonical.showVosotros = !all.contains(where: { !$0.showVosotros })
canonical.autoFillStem = all.contains(where: \.autoFillStem)
let latestTodayDate = all
.map(\.todayDate)
.filter { !$0.isEmpty }
.max() ?? canonical.todayDate
canonical.todayDate = latestTodayDate
canonical.todayCount = all
.filter { $0.todayDate == latestTodayDate }
.map(\.todayCount)
.max() ?? canonical.todayCount
for other in all where other !== canonical {
context.delete(other)
}
}
private static func dedupeReviewCards(context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<ReviewCard>())) ?? []
let groups = Dictionary(grouping: all, by: { ReviewCard.makeKey(verbId: $0.verbId, tenseId: $0.tenseId, personIndex: $0.personIndex) })
for (key, cards) in groups {
guard cards.count > 1 else {
cards.first?.key = key
continue
}
guard let canonical = canonicalReviewCard(from: cards) else { continue }
canonical.key = key
for other in cards where other !== canonical {
context.delete(other)
}
}
}
private static func dedupeCourseReviewCards(context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<CourseReviewCard>())) ?? []
let groups = Dictionary(grouping: all, by: \.id)
for (id, cards) in groups where cards.count > 1 {
guard let canonical = cards.max(by: { reviewScore($0) < reviewScore($1) }) else { continue }
canonical.id = id
for other in cards where other !== canonical {
context.delete(other)
}
}
}
private static func dedupeDailyLogs(context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<DailyLog>())) ?? []
let groups = Dictionary(grouping: all, by: \.dateString)
for (dateString, logs) in groups {
guard let canonical = logs.first else { continue }
canonical.id = DailyLog.makeID(dateString)
if logs.count == 1 { continue }
canonical.reviewCount = logs.reduce(0) { $0 + $1.reviewCount }
canonical.correctCount = logs.reduce(0) { $0 + $1.correctCount }
for other in logs.dropFirst() {
context.delete(other)
}
}
}
private static func score(_ progress: UserProgress) -> Int {
(progress.totalReviewed * 10_000) +
(progress.longestStreak * 100) +
progress.currentStreak
}
private static func canonicalReviewCard(from cards: [ReviewCard]) -> ReviewCard? {
let latest = cards.max {
let lhsDate = $0.lastReviewDate ?? .distantPast
let rhsDate = $1.lastReviewDate ?? .distantPast
if lhsDate != rhsDate { return lhsDate < rhsDate }
if $0.repetitions != $1.repetitions { return $0.repetitions < $1.repetitions }
return $0.interval < $1.interval
}
guard let canonical = latest else { return nil }
canonical.refreshIdentityIfNeeded()
return canonical
}
private static func reviewScore(_ card: CourseReviewCard) -> Int {
let timestamp = Int(card.lastReviewDate?.timeIntervalSince1970 ?? 0)
return (timestamp * 10_000) + (card.repetitions * 100) + card.interval
}
}

View File

@@ -0,0 +1,86 @@
import Foundation
import SharedModels
import SwiftData
import WidgetKit
/// Writes widget data to shared App Group container.
@MainActor
struct WidgetDataService {
static let suiteName = "group.com.conjuga.app"
static let dataKey = "widgetData"
/// Write current app state to shared storage for widgets to read.
static func update(context: ModelContext) {
guard let shared = UserDefaults(suiteName: suiteName) else { return }
// Fetch user progress
let progress = ReviewStore.fetchOrCreateUserProgress(context: context)
// Count due review cards
let now = Date()
let dueDescriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.dueDate <= now }
)
let dueCount = (try? context.fetchCount(dueDescriptor)) ?? 0
var wordOfDay: WordOfDay?
let wordOffset = shared.integer(forKey: "wordOffset")
if let card = CourseCardStore.fetchWordOfDayCard(for: now, wordOffset: wordOffset, context: context) {
let deckId = card.deckId
let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId }
)
let deck = (try? context.fetch(deckDescriptor))?.first
wordOfDay = WordOfDay(
spanish: card.front,
english: card.back,
weekNumber: deck?.weekNumber ?? 1
)
}
// Latest test result
let testDescriptor = FetchDescriptor<TestResult>(
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
)
let latestTest = (try? context.fetch(testDescriptor))?.first
// Determine current week (from most studied decks or latest test)
let currentWeek = latestTest?.weekNumber ?? 1
let previousData = shared.data(forKey: dataKey)
.flatMap { try? JSONDecoder().decode(WidgetData.self, from: $0) }
var data = WidgetData(
todayCount: progress.todayCount,
dailyGoal: progress.dailyGoal,
currentStreak: progress.currentStreak,
dueCardCount: dueCount,
wordOfTheDay: wordOfDay,
latestTestScore: latestTest?.scorePercent,
latestTestWeek: latestTest?.weekNumber,
currentWeek: currentWeek,
lastUpdated: previousData?.lastUpdated ?? now
)
if previousData == data {
return
}
data.lastUpdated = now
if let encoded = try? JSONEncoder().encode(data) {
shared.set(encoded, forKey: dataKey)
WidgetCenter.shared.reloadAllTimelines()
}
}
/// Read widget data (used by widget extension).
static func read() -> WidgetData {
guard let shared = UserDefaults(suiteName: suiteName),
let data = shared.data(forKey: dataKey),
let decoded = try? JSONDecoder().decode(WidgetData.self, from: data) else {
return WidgetData.placeholder
}
return decoded
}
}

View File

@@ -0,0 +1,260 @@
import Foundation
import SwiftData
enum PracticeMode: String, CaseIterable, Identifiable, Sendable {
case flashcard
case typing
case multipleChoice
case fullTable
case handwriting
case sentenceBuilder
var id: String { rawValue }
var label: String {
switch self {
case .flashcard: "Flashcard"
case .typing: "Typing"
case .multipleChoice: "Multiple Choice"
case .fullTable: "Full Table"
case .handwriting: "Handwriting"
case .sentenceBuilder: "Sentence Builder"
}
}
var icon: String {
switch self {
case .flashcard: "rectangle.on.rectangle"
case .typing: "keyboard"
case .multipleChoice: "list.bullet"
case .fullTable: "tablecells"
case .handwriting: "pencil.and.outline"
case .sentenceBuilder: "text.word.spacing"
}
}
}
enum IrregularityFilter: Sendable {
case spelling // 1xx spans
case stemChange // 2xx spans
case uniqueIrregular // 3xx spans
}
enum FocusMode: Sendable {
case none
case weakVerbs
case irregularity(IrregularityFilter)
}
@MainActor
@Observable
final class PracticeViewModel {
// MARK: - Current quiz state
var currentVerb: Verb?
var currentForm: VerbForm?
var currentSpans: [IrregularSpan] = []
var currentTenseInfo: TenseInfo?
var currentPerson: String = ""
var isAnswerRevealed: Bool = false
var userAnswer: String = ""
var isCorrect: Bool?
// MARK: - Session stats
var sessionCorrect: Int = 0
var sessionTotal: Int = 0
// MARK: - Mode
var practiceMode: PracticeMode = .flashcard
var multipleChoiceOptions: [String] = []
var focusMode: FocusMode = .none
// MARK: - Feedback
var newBadges: [Badge] = []
var isLoading: Bool = false
var hasCards: Bool = true
var sessionAccuracy: Double {
guard sessionTotal > 0 else { return 0 }
return Double(sessionCorrect) / Double(sessionTotal)
}
// MARK: - Load next card
func loadNextCard(context: ModelContext) {
isAnswerRevealed = false
userAnswer = ""
isCorrect = nil
multipleChoiceOptions = []
currentSpans = []
hasCards = true
isLoading = true
let service = PracticeSessionService(context: context)
guard let cardLoad = service.nextCard(for: focusMode) else {
clearCurrentCard()
hasCards = false
isLoading = false
return
}
applyCardLoad(cardLoad, context: context)
isLoading = false
}
// MARK: - Answer checking
func checkAnswer(_ answer: String) -> Bool {
guard let form = currentForm else { return false }
let trimmedAnswer = answer.trimmingCharacters(in: .whitespacesAndNewlines)
let correctForm = form.form.trimmingCharacters(in: .whitespacesAndNewlines)
// Case-insensitive but accent-sensitive comparison
let result = trimmedAnswer.compare(correctForm, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
isCorrect = result
isAnswerRevealed = true
if result {
sessionCorrect += 1
}
sessionTotal += 1
return result
}
func revealAnswer() {
isAnswerRevealed = true
}
// MARK: - SRS rating
func rateAnswer(quality: ReviewQuality, context: ModelContext) {
guard let form = currentForm else { return }
let service = PracticeSessionService(context: context)
newBadges = service.rate(
verbId: form.verbId,
tenseId: form.tenseId,
personIndex: form.personIndex,
quality: quality
)
}
// MARK: - Distractor generation
func generateDistractors(for form: VerbForm, context: ModelContext) -> [String] {
let personIndex = form.personIndex
let correctForm = form.form
let verbId = form.verbId
let tenseId = form.tenseId
// Same verb, same person, DIFFERENT tenses much harder
let descriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { f in
f.verbId == verbId &&
f.personIndex == personIndex &&
f.tenseId != tenseId
}
)
let samVerbForms = (try? context.fetch(descriptor)) ?? []
var distractors: [String] = []
var seen: Set<String> = [correctForm.lowercased()]
for candidate in samVerbForms.shuffled() {
let lower = candidate.form.lowercased()
if !seen.contains(lower) {
seen.insert(lower)
distractors.append(candidate.form)
}
if distractors.count >= 3 { break }
}
// Fallback: if not enough same-verb forms, use same tense different verbs
if distractors.count < 3 {
let fallbackDescriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { f in
f.tenseId == tenseId &&
f.personIndex == personIndex &&
f.verbId != verbId
}
)
let otherForms = (try? context.fetch(fallbackDescriptor)) ?? []
for candidate in otherForms.shuffled() {
let lower = candidate.form.lowercased()
if !seen.contains(lower) {
seen.insert(lower)
distractors.append(candidate.form)
}
if distractors.count >= 3 { break }
}
}
// Final fallback: fabricate
while distractors.count < 3 {
let filler = fabricateDistractor(from: correctForm, existing: seen)
seen.insert(filler.lowercased())
distractors.append(filler)
}
return distractors
}
// MARK: - Private helpers
private func applyCardLoad(_ cardLoad: PracticeCardLoad, context: ModelContext) {
currentVerb = cardLoad.verb
currentForm = cardLoad.form
currentSpans = cardLoad.spans
currentTenseInfo = cardLoad.tenseInfo
currentPerson = cardLoad.person
if practiceMode == .multipleChoice {
prepareMultipleChoice(for: cardLoad.form, context: context)
}
}
private func clearCurrentCard() {
currentVerb = nil
currentForm = nil
currentSpans = []
currentTenseInfo = nil
currentPerson = ""
}
private func prepareMultipleChoice(for form: VerbForm, context: ModelContext) {
let distractors = generateDistractors(for: form, context: context)
var options = distractors + [form.form]
options.shuffle()
multipleChoiceOptions = options
}
private func fabricateDistractor(from correct: String, existing: Set<String>) -> String {
// Simple strategy: swap common Spanish verb endings
let endings = ["o", "as", "a", "amos", "áis", "an",
"es", "e", "emos", "éis", "en",
"í", "iste", "", "imos", "isteis", "ieron"]
for ending in endings.shuffled() {
// Try replacing last 1-4 characters
for len in [3, 2, 4, 1] {
if correct.count > len {
let base = String(correct.prefix(correct.count - len))
let candidate = base + ending
if !existing.contains(candidate.lowercased()) && candidate != correct {
return candidate
}
}
}
}
// Fallback: append an extra letter
return correct + "s"
}
}

View File

@@ -0,0 +1,20 @@
import SwiftUI
/// Constrains content to a max readable width and centers it.
/// On iPhone this has no effect (content is already narrow).
/// On iPad this prevents content from stretching edge-to-edge.
struct AdaptiveContainer: ViewModifier {
var maxWidth: CGFloat = 700
func body(content: Content) -> some View {
content
.frame(maxWidth: maxWidth)
.frame(maxWidth: .infinity)
}
}
extension View {
func adaptiveContainer(maxWidth: CGFloat = 700) -> some View {
modifier(AdaptiveContainer(maxWidth: maxWidth))
}
}

View File

@@ -0,0 +1,45 @@
import SwiftUI
struct DailyProgressRing: View {
let current: Int
let goal: Int
private var progress: Double {
guard goal > 0 else { return 0 }
return min(Double(current) / Double(goal), 1.0)
}
var body: some View {
ZStack {
Circle()
.stroke(Color.blue.opacity(0.2), lineWidth: 16)
Circle()
.trim(from: 0, to: progress)
.stroke(
Color.blue,
style: StrokeStyle(lineWidth: 16, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.5), value: progress)
VStack(spacing: 4) {
Text("\(Int(progress * 100))%")
.font(.system(.title, design: .rounded, weight: .bold))
.contentTransition(.numericText())
if current >= goal {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundStyle(.green)
}
}
}
}
}
#Preview {
DailyProgressRing(current: 35, goal: 50)
.frame(width: 200, height: 200)
.padding()
}

View File

@@ -0,0 +1,53 @@
import SwiftUI
import PencilKit
/// SwiftUI wrapper for PKCanvasView that supports Apple Pencil and finger drawing.
struct HandwritingCanvas: UIViewRepresentable {
@Binding var drawing: PKDrawing
var onDrawingChanged: (() -> Void)?
func makeUIView(context: Context) -> PKCanvasView {
let canvas = PKCanvasView()
canvas.drawing = drawing
canvas.tool = PKInkingTool(.pen, color: .label, width: 4)
canvas.drawingPolicy = .anyInput // Pencil + finger
canvas.backgroundColor = .clear
canvas.isOpaque = false
canvas.delegate = context.coordinator
// Show tool picker
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
let toolPicker = PKToolPicker()
toolPicker.setVisible(true, forFirstResponder: canvas)
toolPicker.addObserver(canvas)
canvas.becomeFirstResponder()
context.coordinator.toolPicker = toolPicker
}
return canvas
}
func updateUIView(_ uiView: PKCanvasView, context: Context) {
if uiView.drawing != drawing {
uiView.drawing = drawing
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PKCanvasViewDelegate {
let parent: HandwritingCanvas
var toolPicker: PKToolPicker?
init(_ parent: HandwritingCanvas) {
self.parent = parent
}
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
parent.drawing = canvasView.drawing
parent.onDrawingChanged?()
}
}
}

View File

@@ -0,0 +1,91 @@
import SwiftUI
struct IrregularHighlightText: View {
let form: String
let spans: [IrregularSpan]
var font: Font = .title
var showLabels: Bool = true
private var attributedForm: AttributedString {
var result = AttributedString(form)
for span in spans {
let startIndex = form.index(form.startIndex, offsetBy: span.start, limitedBy: form.endIndex) ?? form.startIndex
let endIndex = form.index(form.startIndex, offsetBy: span.end, limitedBy: form.endIndex) ?? form.endIndex
guard let attrStart = AttributedString.Index(startIndex, within: result),
let attrEnd = AttributedString.Index(endIndex, within: result) else {
continue
}
result[attrStart..<attrEnd].foregroundColor = colorForSpan(span)
}
return result
}
private var uniqueCategories: [IrregularSpan.SpanCategory] {
var seen = Set<String>()
return spans.compactMap { span in
let cat = span.category
if seen.insert(cat.rawValue).inserted {
return cat
}
return nil
}
}
var body: some View {
VStack(spacing: 8) {
Text(attributedForm)
.font(font)
if showLabels && !spans.isEmpty {
HStack(spacing: 12) {
ForEach(uniqueCategories, id: \.rawValue) { category in
Label(category.rawValue, systemImage: symbolForCategory(category))
.font(.caption)
.foregroundStyle(colorForCategory(category))
}
}
}
}
}
private func colorForSpan(_ span: IrregularSpan) -> Color {
colorForCategory(span.category)
}
private func colorForCategory(_ category: IrregularSpan.SpanCategory) -> Color {
switch category {
case .spelling: .orange
case .stemChange: .blue
case .uniqueIrregular: .red
}
}
private func symbolForCategory(_ category: IrregularSpan.SpanCategory) -> String {
switch category {
case .spelling: "textformat.abc"
case .stemChange: "leaf"
case .uniqueIrregular: "star"
}
}
}
#Preview {
VStack(spacing: 30) {
IrregularHighlightText(
form: "tengo",
spans: []
)
IrregularHighlightText(
form: "tengo",
spans: [
IrregularSpan(verbId: 1, tenseId: "ind_presente", personIndex: 0, spanType: 300, pattern: 301, start: 0, end: 4)
]
)
}
.padding()
}

View File

@@ -0,0 +1,134 @@
import SwiftUI
import SwiftData
/// Reusable tappable tense pill that shows a tense info sheet when tapped.
/// Use this anywhere a tense name is displayed as a badge/pill.
struct TensePill: View {
let tenseInfo: TenseInfo
@State private var showingInfo = false
var body: some View {
Button {
showingInfo = true
} label: {
HStack(spacing: 4) {
Text(tenseInfo.english)
.font(.caption.weight(.semibold))
Image(systemName: "info.circle")
.font(.caption2)
}
.padding(.horizontal, 12)
.padding(.vertical, 5)
.background(.tint.opacity(0.15), in: .capsule)
.foregroundStyle(.tint)
}
.sheet(isPresented: $showingInfo) {
TensePillSheet(tenseInfo: tenseInfo)
.presentationDetents([.medium])
}
}
}
/// Also support creating from a tense ID string
extension TensePill {
init?(tenseId: String) {
guard let info = TenseInfo.find(tenseId) else { return nil }
self.init(tenseInfo: info)
}
}
// MARK: - Sheet
private struct TensePillSheet: View {
let tenseInfo: TenseInfo
@Environment(\.dismiss) private var dismiss
@Query private var guides: [TenseGuide]
private var guide: TenseGuide? {
guides.first { $0.tenseId == tenseInfo.id }
}
private var description: String {
guard let guide else { return "" }
let body = guide.body
let lines = body.components(separatedBy: "\n")
var introLines: [String] = []
var usageHeaders: [String] = []
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { continue }
let usagePattern = /^\*{1,2}(\d+)\s+(.+?)\*{0,2}$/
if let match = trimmed.firstMatch(of: usagePattern) {
let title = String(match.2).replacingOccurrences(of: "*", with: "")
usageHeaders.append(title)
continue
}
if trimmed.hasPrefix("*Usages") || trimmed.hasPrefix("**Usages") { continue }
if usageHeaders.isEmpty {
let clean = trimmed.replacingOccurrences(of: "**", with: "").replacingOccurrences(of: "*", with: "")
if !clean.hasPrefix("-") && !clean.hasPrefix("¿") && !clean.hasPrefix("¡") {
introLines.append(clean)
}
}
}
var parts: [String] = []
let intro = introLines.joined(separator: " ")
if !intro.isEmpty { parts.append(intro) }
if !usageHeaders.isEmpty {
let summary = usageHeaders.enumerated().map { i, h in "\(i + 1). \(h)" }.joined(separator: "\n")
parts.append("Used for:\n" + summary)
}
return parts.isEmpty ? guide.title.replacingOccurrences(of: "*", with: "") : parts.joined(separator: "\n\n")
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(tenseInfo.spanish)
.font(.title2.weight(.bold))
Text(tenseInfo.english)
.font(.title3)
.foregroundStyle(.secondary)
Label(tenseInfo.mood, systemImage: "tag")
.font(.subheadline.weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
Divider()
if !description.isEmpty {
Text(description)
.font(.body)
.lineSpacing(4)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
.navigationTitle("About this tense")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
}
#Preview {
TensePill(tenseInfo: TenseInfo.all[0])
.modelContainer(for: TenseGuide.self, inMemory: true)
}

View File

@@ -0,0 +1,526 @@
import SwiftUI
import SharedModels
import SwiftData
import PencilKit
struct CourseQuizView: View {
let cards: [VocabCard]
let quizType: QuizType
let weekNumber: Int
let isFocusMode: Bool
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var speechService = SpeechService()
@State private var shuffledCards: [VocabCard] = []
@State private var currentIndex = 0
@State private var correctCount = 0
@State private var missedItems: [MissedCourseItem] = []
// Per-question state
@State private var userAnswer = ""
@State private var selectedOption: Int?
@State private var isAnswered = false
@State private var isCorrect = false
@State private var options: [String] = []
@State private var hwDrawing = PKDrawing()
@State private var hwRecognizedText = ""
@State private var isRecognizing = false
@FocusState private var isTypingFocused: Bool
private var isComplete: Bool { currentIndex >= shuffledCards.count }
private var currentCard: VocabCard? {
guard currentIndex < shuffledCards.count else { return nil }
return shuffledCards[currentIndex]
}
private var progress: Double {
guard !shuffledCards.isEmpty else { return 0 }
return Double(currentIndex) / Double(shuffledCards.count)
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
if isComplete {
completionView
} else if let card = currentCard {
// Progress bar
VStack(spacing: 4) {
ProgressView(value: progress)
.tint(isFocusMode ? .red : .orange)
Text("\(currentIndex + 1) of \(shuffledCards.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
// Prompt
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)
if quizType.promptLanguage == "Spanish" {
Button { speechService.speak(card.front) } label: {
Image(systemName: "speaker.wave.2")
.font(.title3)
}
.tint(.secondary)
}
}
.padding(.top, 8)
// Answer area
if quizType.isMultipleChoice {
multipleChoiceArea(card: card)
} else if quizType.isHandwriting {
handwritingArea(card: card)
} else {
typingArea(card: card)
}
// Post-answer feedback + navigation
if isAnswered {
feedbackArea(card: card)
}
}
}
.padding()
.adaptiveContainer()
}
.navigationTitle(isFocusMode ? "Focus Area" : "Week \(weekNumber) Test")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Exit") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Text("\(correctCount)/\(currentIndex)")
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
}
}
.onAppear {
shuffledCards = cards.shuffled()
prepareQuestion()
}
}
// MARK: - Multiple Choice
private func multipleChoiceArea(card: VocabCard) -> some View {
VStack(spacing: 10) {
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
Button {
guard !isAnswered else { return }
selectedOption = index
checkMCAnswer(option, card: card)
} label: {
HStack {
Text(option)
.font(.body.weight(.medium))
Spacer()
if isAnswered {
if option == quizType.answer(for: card) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
} else if index == selectedOption {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 18)
.padding(.vertical, 14)
}
.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)
.disabled(isAnswered)
}
}
.padding(.horizontal)
}
private func mcTint(index: Int, option: String, card: VocabCard) -> Color {
guard isAnswered else { return .primary }
if option == quizType.answer(for: card) { return .green }
if index == selectedOption { return .red }
return .secondary
}
// MARK: - Handwriting
private func handwritingArea(card: VocabCard) -> some View {
VStack(spacing: 14) {
if isAnswered {
if isCorrect {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Correct!")
.font(.title3.weight(.bold))
.foregroundStyle(.green)
}
} else {
VStack(spacing: 8) {
// What was recognized
HStack(spacing: 8) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
Text(hwRecognizedText.isEmpty ? "Nothing recognized" : "Recognized: \"\(hwRecognizedText)\"")
.font(.subheadline)
.foregroundStyle(.red)
}
// Correct answer
VStack(spacing: 2) {
Text("Correct answer:")
.font(.caption)
.foregroundStyle(.secondary)
Text(quizType.answer(for: card))
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
}
}
}
} else {
HandwritingCanvas(drawing: $hwDrawing)
.frame(height: 180)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(.separator, lineWidth: 1)
)
if !hwRecognizedText.isEmpty {
Text("Recognized: \"\(hwRecognizedText)\"")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 16) {
Button {
hwDrawing = PKDrawing()
hwRecognizedText = ""
} label: {
Label("Clear", systemImage: "trash")
.font(.subheadline)
}
.tint(.secondary)
Button {
submitHandwriting(card: card)
} label: {
HStack {
if isRecognizing {
ProgressView()
.tint(.white)
}
Text("Check")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.disabled(hwDrawing.strokes.isEmpty || isRecognizing)
}
}
}
.padding(.horizontal)
}
// MARK: - Typing
private func typingArea(card: VocabCard) -> some View {
VStack(spacing: 14) {
if isAnswered {
if isCorrect {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(userAnswer)
.font(.title3.weight(.medium))
.foregroundStyle(.green)
}
} else {
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
Text(userAnswer)
.font(.body)
.foregroundStyle(.red)
.strikethrough()
}
VStack(spacing: 2) {
Text("Correct answer:")
.font(.caption)
.foregroundStyle(.secondary)
Text(quizType.answer(for: card))
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
}
}
}
} else {
TextField("Type \(quizType.answerLanguage) translation...", text: $userAnswer)
.font(.title3)
.multilineTextAlignment(.center)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($isTypingFocused)
.submitLabel(.done)
.onSubmit { submitTypingAnswer(card: card) }
.padding()
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
Button {
submitTypingAnswer(card: card)
} label: {
Text("Check")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.disabled(userAnswer.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding(.horizontal)
}
// MARK: - Feedback + Navigation
private func feedbackArea(card: VocabCard) -> some View {
VStack(spacing: 14) {
Divider()
// Nav arrows
HStack {
Button {
guard currentIndex > 0 else { return }
currentIndex -= 1
resetQuestion()
prepareQuestion()
} label: {
Label("Previous", systemImage: "chevron.left")
.font(.subheadline)
}
.tint(.secondary)
.disabled(currentIndex == 0)
Spacer()
Button {
advance()
} label: {
Label("Next", systemImage: "chevron.right")
.font(.subheadline.weight(.semibold))
.labelStyle(.titleAndIcon)
}
.tint(.blue)
}
.padding(.horizontal)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// MARK: - Completion
private var completionView: some View {
VStack(spacing: 20) {
let percent = shuffledCards.isEmpty ? 0 : Int(round(Double(correctCount) / Double(shuffledCards.count) * 100))
Image(systemName: percent >= 90 ? "star.fill" : percent >= 70 ? "hand.thumbsup.fill" : "arrow.clockwise")
.font(.system(size: 56))
.foregroundStyle(percent >= 90 ? .yellow : percent >= 70 ? .green : .orange)
Text("\(percent)%")
.font(.system(size: 60, weight: .bold, design: .rounded))
Text("\(correctCount) of \(shuffledCards.count) correct")
.font(.title3)
.foregroundStyle(.secondary)
if !missedItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Missed Items")
.font(.headline)
.foregroundStyle(.red)
VStack(spacing: 0) {
ForEach(Array(missedItems.enumerated()), id: \.offset) { _, item in
HStack {
Text(item.front)
.font(.subheadline.weight(.medium))
Spacer()
Text(item.back)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
Divider().padding(.leading, 12)
}
}
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
}
.padding(.horizontal)
}
HStack(spacing: 16) {
Button {
// Retake
correctCount = 0
currentIndex = 0
missedItems = []
shuffledCards = cards.shuffled()
resetQuestion()
prepareQuestion()
} label: {
Label("Retake", systemImage: "arrow.clockwise")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
Button { dismiss() } label: {
Text("Done")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
}
.padding(.horizontal)
}
.padding(.top, 40)
}
// MARK: - Logic
private func prepareQuestion() {
guard let card = currentCard else { return }
isAnswered = false
isCorrect = false
selectedOption = nil
userAnswer = ""
if quizType.isMultipleChoice {
options = generateOptions(for: card)
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
isTypingFocused = true
}
}
}
private func resetQuestion() {
isAnswered = false
isCorrect = false
selectedOption = nil
userAnswer = ""
options = []
hwDrawing = PKDrawing()
hwRecognizedText = ""
isRecognizing = false
}
private func submitHandwriting(card: VocabCard) {
isRecognizing = true
Task {
let result = await HandwritingRecognizer.recognize(drawing: hwDrawing)
await MainActor.run {
hwRecognizedText = result.text
isRecognizing = false
let correct = quizType.answer(for: card)
isCorrect = HandwritingRecognizer.checkAnswer(result: result, expected: correct)
recordAnswer(card: card)
}
}
}
private func generateOptions(for card: VocabCard) -> [String] {
let correct = quizType.answer(for: card)
var distractors: [String] = []
var seen: Set<String> = [correct.lowercased()]
// Pull distractors from all cards in the set
for other in shuffledCards.shuffled() {
let ans = quizType.answer(for: other)
let lower = ans.lowercased()
if !seen.contains(lower) {
seen.insert(lower)
distractors.append(ans)
}
if distractors.count >= 3 { break }
}
var all = distractors + [correct]
all.shuffle()
return all
}
private func checkMCAnswer(_ selected: String, card: VocabCard) {
let correct = quizType.answer(for: card)
isCorrect = selected.compare(correct, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
recordAnswer(card: card)
}
private func submitTypingAnswer(card: VocabCard) {
let trimmed = userAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let correct = quizType.answer(for: card)
isCorrect = trimmed.compare(correct, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
recordAnswer(card: card)
}
private func recordAnswer(card: VocabCard) {
isAnswered = true
if isCorrect {
correctCount += 1
} else {
missedItems.append(MissedCourseItem(front: card.front, back: card.back))
}
}
private func advance() {
currentIndex += 1
if isComplete {
saveResult()
} else {
resetQuestion()
prepareQuestion()
}
}
private func saveResult() {
let result = TestResult(
weekNumber: weekNumber,
quizType: quizType.rawValue,
totalQuestions: shuffledCards.count,
correctCount: correctCount,
missedItems: missedItems
)
modelContext.insert(result)
try? modelContext.save()
}
}
#Preview {
NavigationStack {
CourseQuizView(cards: [], quizType: .mcEsToEn, weekNumber: 1, isFocusMode: false)
}
.modelContainer(for: [TestResult.self, VocabCard.self], inMemory: true)
}

View File

@@ -0,0 +1,160 @@
import SwiftUI
import SharedModels
import SwiftData
struct CourseView: View {
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
@Query private var testResults: [TestResult]
@AppStorage("selectedCourse") private var selectedCourse: String?
private var courseNames: [String] {
let names = Set(decks.map(\.courseName))
return names.sorted()
}
private var activeCourse: String {
selectedCourse ?? courseNames.first ?? ""
}
private var filteredDecks: [CourseDeck] {
decks.filter { $0.courseName == activeCourse }
}
private var weekGroups: [(week: Int, decks: [CourseDeck])] {
let grouped = Dictionary(grouping: filteredDecks, by: \.weekNumber)
return grouped.keys.sorted().map { week in
(week, grouped[week]!.sorted { $0.title < $1.title })
}
}
private func bestScore(for week: Int) -> Int? {
let results = testResults.filter { $0.weekNumber == week }
guard !results.isEmpty else { return nil }
return results.map(\.scorePercent).max()
}
private func shortName(_ full: String) -> String {
full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
.replacingOccurrences(of: "LanGo Spanish ", with: "")
}
var body: some View {
NavigationStack {
List {
if decks.isEmpty {
ContentUnavailableView(
"No Course Data",
systemImage: "tray",
description: Text("Course data is loading...")
)
} else {
// Course picker
if courseNames.count > 1 {
Section {
Picker("Course", selection: Binding(
get: { activeCourse },
set: { selectedCourse = $0 }
)) {
ForEach(courseNames, id: \.self) { name in
Text(shortName(name)).tag(name)
}
}
.pickerStyle(.menu)
}
}
// Week sections
ForEach(weekGroups, id: \.week) { week, weekDecks in
Section {
// Test button
NavigationLink(value: week) {
HStack(spacing: 12) {
Image(systemName: "pencil.and.list.clipboard")
.font(.title3)
.foregroundStyle(.orange)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Take Test")
.font(.subheadline.weight(.semibold))
Text("Multiple quiz types available")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let best = bestScore(for: week) {
Text("\(best)%")
.font(.subheadline.weight(.bold))
.foregroundStyle(best >= 90 ? .green : best >= 70 ? .orange : .red)
}
}
}
// Deck rows
ForEach(weekDecks) { deck in
NavigationLink(value: deck) {
DeckRowView(deck: deck)
}
}
} header: {
Text("Week \(week)")
}
}
}
}
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
.navigationDestination(for: CourseDeck.self) { deck in
DeckStudyView(deck: deck)
}
.navigationDestination(for: Int.self) { week in
WeekTestView(weekNumber: week)
}
}
}
}
// MARK: - Deck Row
private struct DeckRowView: View {
let deck: CourseDeck
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(deck.title)
.font(.headline)
HStack(spacing: 8) {
Label("\(deck.cardCount) cards", systemImage: "rectangle.on.rectangle")
.font(.caption)
.foregroundStyle(.secondary)
if deck.isReversed {
Text("EN → ES")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.blue.opacity(0.12), in: Capsule())
.foregroundStyle(.blue)
} else {
Text("ES → EN")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.orange.opacity(0.12), in: Capsule())
.foregroundStyle(.orange)
}
}
}
Spacer()
}
}
}
#Preview {
CourseView()
.modelContainer(for: [CourseDeck.self, TestResult.self], inMemory: true)
}

View File

@@ -0,0 +1,151 @@
import SwiftUI
import SharedModels
import SwiftData
struct DeckStudyView: View {
let deck: CourseDeck
@Environment(\.modelContext) private var modelContext
@State private var isStudying = false
@State private var speechService = SpeechService()
@State private var deckCards: [VocabCard] = []
var body: some View {
cardListView
.navigationTitle(deck.title)
.navigationBarTitleDisplayMode(.inline)
.onAppear { loadCards() }
.fullScreenCover(isPresented: $isStudying) {
NavigationStack {
VocabFlashcardView(
cards: deckCards.shuffled(),
speechService: speechService,
onDone: { isStudying = false }
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { isStudying = false }
}
}
}
}
}
private func loadCards() {
let deckId = deck.id
let descriptor = FetchDescriptor<VocabCard>(
predicate: #Predicate<VocabCard> { $0.deckId == deckId },
sortBy: [SortDescriptor(\VocabCard.front)]
)
deckCards = (try? modelContext.fetch(descriptor)) ?? []
}
// MARK: - Card List
private var cardListView: some View {
ScrollView {
VStack(spacing: 16) {
// Header
VStack(spacing: 8) {
Text("Week \(deck.weekNumber)")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(deck.title)
.font(.title2.weight(.bold))
HStack(spacing: 12) {
Label("\(deckCards.count) cards", systemImage: "rectangle.on.rectangle")
if deck.isReversed {
Label("EN → ES", systemImage: "arrow.right")
} else {
Label("ES → EN", systemImage: "arrow.right")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8)
// Study button
Button {
withAnimation { isStudying = true }
} label: {
Label("Study Flashcards", systemImage: "brain.head.profile")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.padding(.horizontal)
// Card list
LazyVStack(spacing: 0) {
ForEach(Array(deckCards.enumerated()), id: \.offset) { _, card in
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button {
speechService.speak(card.front)
} label: {
Image(systemName: "speaker.wave.2")
.font(.body)
.foregroundStyle(.blue)
.frame(width: 32, height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.borderless)
Text(card.front)
.font(.body.weight(.medium))
Spacer()
Text(card.back)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
// Example sentences
if !card.examplesES.isEmpty {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(zip(card.examplesES, card.examplesEN).enumerated()), id: \.offset) { _, pair in
VStack(alignment: .leading, spacing: 2) {
Text(pair.0)
.font(.caption)
.italic()
Text(pair.1)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.leading, 42)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
.padding(.leading, 16)
}
}
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
.padding(.vertical)
.adaptiveContainer()
}
}
}
#Preview {
NavigationStack {
DeckStudyView(deck: CourseDeck(
id: "test", weekNumber: 1, title: "Greetings", cardCount: 5,
courseName: "Test", isReversed: false
))
}
.modelContainer(for: [CourseDeck.self, VocabCard.self], inMemory: true)
}

View File

@@ -0,0 +1,198 @@
import SwiftUI
import SharedModels
import SwiftData
struct VocabFlashcardView: View {
let cards: [VocabCard]
let speechService: SpeechService
let onDone: () -> Void
@Environment(\.modelContext) private var modelContext
@State private var currentIndex = 0
@State private var isRevealed = false
@State private var sessionCorrect = 0
private var currentCard: VocabCard? {
guard currentIndex < cards.count else { return nil }
return cards[currentIndex]
}
private var progress: Double {
guard !cards.isEmpty else { return 0 }
return Double(currentIndex) / Double(cards.count)
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Progress
VStack(spacing: 6) {
ProgressView(value: progress)
.tint(.orange)
Text("\(currentIndex + 1) of \(cards.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
if let card = currentCard {
// Front (question)
Text(card.front)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 20)
// Card area
VStack(spacing: 16) {
if isRevealed {
VStack(spacing: 12) {
Text(card.back)
.font(.title.weight(.medium))
.multilineTextAlignment(.center)
Button {
speechService.speak(card.front)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title3)
.padding(12)
}
.glassEffect(in: .circle)
}
.transition(.blurReplace)
} else {
VStack(spacing: 8) {
Image(systemName: "hand.tap")
.font(.title)
.foregroundStyle(.secondary)
Text("Tap to reveal")
.font(.headline)
.foregroundStyle(.secondary)
}
.transition(.blurReplace)
}
}
.frame(maxWidth: .infinity)
.frame(minHeight: 160)
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
.overlay {
if !isRevealed {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.smooth) { isRevealed = true }
}
}
}
.padding(.horizontal)
// Rating + navigation
if isRevealed {
VStack(spacing: 12) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
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)
}
.padding(.horizontal)
// Navigation arrows
HStack {
Button {
guard currentIndex > 0 else { return }
withAnimation(.smooth) {
isRevealed = false
currentIndex -= 1
}
} label: {
Label("Previous", systemImage: "chevron.left")
.font(.subheadline)
}
.tint(.secondary)
.disabled(currentIndex == 0)
Spacer()
Button {
withAnimation(.smooth) {
isRevealed = false
currentIndex += 1
}
} label: {
Label("Skip", systemImage: "chevron.right")
.font(.subheadline)
.labelStyle(.titleAndIcon)
}
.tint(.secondary)
}
.padding(.horizontal)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
} else {
// Done
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundStyle(.green)
Text("Deck Complete!")
.font(.title.bold())
Text("\(sessionCorrect) / \(cards.count) correct")
.font(.title3)
.foregroundStyle(.secondary)
Button("Done") { onDone() }
.buttonStyle(.borderedProminent)
.tint(.orange)
.padding(.top, 8)
}
.padding(.top, 60)
}
}
.padding(.vertical)
.adaptiveContainer()
}
.animation(.smooth, value: isRevealed)
.animation(.smooth, value: currentIndex)
}
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
Button {
rateAndAdvance(quality: quality)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
private func rateAndAdvance(quality: ReviewQuality) {
guard let card = currentCard else { return }
CourseReviewStore(context: modelContext).rate(card: card, quality: quality)
if quality.rawValue >= 3 { sessionCorrect += 1 }
// Next card
withAnimation(.smooth) {
isRevealed = false
currentIndex += 1
}
}
}
#Preview {
VocabFlashcardView(cards: [], speechService: SpeechService(), onDone: {})
.modelContainer(for: [VocabCard.self, CourseReviewCard.self], inMemory: true)
}

View File

@@ -0,0 +1,235 @@
import SwiftUI
import SharedModels
import SwiftData
struct WeekTestView: View {
let weekNumber: Int
@Environment(\.modelContext) private var modelContext
@Query private var allResults: [TestResult]
@Query private var allDecks: [CourseDeck]
@State private var loadedWeekCards: [VocabCard] = []
private var weekResults: [TestResult] {
allResults
.filter { $0.weekNumber == weekNumber }
.sorted { $0.dateTaken > $1.dateTaken }
}
private var weekCards: [VocabCard] {
loadedWeekCards
}
private var missedItems: [MissedCourseItem] {
var missed: [String: MissedCourseItem] = [:]
for result in weekResults {
for item in result.missedItems {
missed[item.front] = item
}
}
if let latest = weekResults.first {
let latestMissed = Set(latest.missedItems.map(\.front))
missed = missed.filter { latestMissed.contains($0.key) }
}
return missed.values.sorted { $0.front < $1.front }
}
private var focusCards: [VocabCard] {
let missedFronts = Set(missedItems.map(\.front))
return weekCards.filter { missedFronts.contains($0.front) }
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Header
VStack(spacing: 4) {
Text("Week \(weekNumber)")
.font(.largeTitle.weight(.bold))
Text("Test Your Knowledge")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
// Quiz type selection
VStack(alignment: .leading, spacing: 12) {
Text("Choose Quiz Type")
.font(.headline)
.padding(.horizontal)
ForEach(QuizType.allCases) { type in
NavigationLink {
CourseQuizView(
cards: weekCards,
quizType: type,
weekNumber: weekNumber,
isFocusMode: false
)
} label: {
HStack(spacing: 14) {
Image(systemName: type.icon)
.font(.title3)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(type.label)
.font(.subheadline.weight(.semibold))
Text(type.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
}
}
// Focus Area
if !missedItems.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("Focus Area", systemImage: "target")
.font(.headline)
.foregroundStyle(.red)
Spacer()
Text("\(missedItems.count) items")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
// Missed items preview
VStack(spacing: 0) {
ForEach(Array(missedItems.prefix(5).enumerated()), id: \.offset) { _, item in
HStack {
Text(item.front)
.font(.subheadline.weight(.medium))
Spacer()
Text(item.back)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
Divider().padding(.leading, 14)
}
if missedItems.count > 5 {
Text("+ \(missedItems.count - 5) more")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 8)
}
}
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
// Study focus button
NavigationLink {
CourseQuizView(
cards: focusCards,
quizType: .mcEsToEn,
weekNumber: weekNumber,
isFocusMode: true
)
} label: {
Label("Study Focus Area", systemImage: "brain.head.profile")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.padding(.horizontal)
}
}
// Score History
if !weekResults.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("Score History")
.font(.headline)
.padding(.horizontal)
VStack(spacing: 0) {
ForEach(Array(weekResults.prefix(10).enumerated()), id: \.offset) { _, result in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(QuizType(rawValue: result.quizType)?.label ?? result.quizType)
.font(.subheadline.weight(.medium))
Text(result.dateTaken.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.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("Week \(weekNumber) Test")
.navigationBarTitleDisplayMode(.inline)
.onAppear { loadCards() }
}
private func loadCards() {
let weekDecks = allDecks.filter { $0.weekNumber == weekNumber && !$0.isReversed }
let deckIds = weekDecks.map(\.id)
var cards: [VocabCard] = []
for deckId in deckIds {
let descriptor = FetchDescriptor<VocabCard>(
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
)
if let fetched = try? modelContext.fetch(descriptor) {
cards.append(contentsOf: fetched)
}
}
loadedWeekCards = cards
}
private func scoreColor(_ percent: Int) -> Color {
if percent >= 90 { return .green }
if percent >= 70 { return .orange }
return .red
}
}
#Preview {
NavigationStack {
WeekTestView(weekNumber: 1)
}
.modelContainer(for: [TestResult.self, CourseDeck.self, VocabCard.self], inMemory: true)
}

View File

@@ -0,0 +1,201 @@
import SwiftUI
import SwiftData
import Charts
struct DashboardView: View {
@Query private var progress: [UserProgress]
@Query(sort: \DailyLog.dateString, order: .reverse) private var dailyLogs: [DailyLog]
@Query private var testResults: [TestResult]
@Query private var reviewCards: [ReviewCard]
private var userProgress: UserProgress? { progress.first }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Summary stats
statsGrid
// Streak calendar
streakCalendar
// Accuracy chart
accuracyChart
// Test scores
if !testResults.isEmpty {
testScoresSection
}
}
.padding()
.adaptiveContainer(maxWidth: 800)
}
.navigationTitle("Dashboard")
}
}
// MARK: - Stats Grid
private var statsGrid: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
StatCard(title: "Total Reviews", value: "\(userProgress?.totalReviewed ?? 0)", icon: "checkmark.circle", color: .blue)
StatCard(title: "Current Streak", value: "\(userProgress?.currentStreak ?? 0)d", icon: "flame.fill", color: .orange)
StatCard(title: "Longest Streak", value: "\(userProgress?.longestStreak ?? 0)d", icon: "trophy.fill", color: .yellow)
let mastered = reviewCards.filter { $0.interval >= 21 }.count
StatCard(title: "Verbs Mastered", value: "\(mastered)", icon: "star.fill", color: .green)
let avgAccuracy = averageAccuracy
StatCard(title: "Avg Accuracy", value: "\(avgAccuracy)%", icon: "target", color: .purple)
StatCard(title: "Tests Taken", value: "\(testResults.count)", icon: "pencil.and.list.clipboard", color: .indigo)
}
}
// MARK: - Streak Calendar
private var streakCalendar: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Activity")
.font(.headline)
StreakCalendarView(dailyLogs: dailyLogs)
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
// MARK: - Accuracy Chart
private var accuracyChart: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Accuracy (Last 30 Days)")
.font(.headline)
if recentLogs.isEmpty {
Text("Start practicing to see your accuracy trend")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(height: 150)
} else {
Chart(recentLogs, id: \.dateString) { log in
LineMark(
x: .value("Date", DailyLog.date(from: log.dateString) ?? Date()),
y: .value("Accuracy", log.accuracy * 100)
)
.foregroundStyle(.blue)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Date", DailyLog.date(from: log.dateString) ?? Date()),
y: .value("Accuracy", log.accuracy * 100)
)
.foregroundStyle(.blue.opacity(0.1))
.interpolationMethod(.catmullRom)
}
.chartYScale(domain: 0...100)
.chartYAxis {
AxisMarks(values: [0, 25, 50, 75, 100]) { value in
AxisValueLabel {
Text("\(value.as(Int.self) ?? 0)%")
.font(.caption2)
}
AxisGridLine()
}
}
.frame(height: 180)
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
// MARK: - Test Scores
private var testScoresSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Recent Tests")
.font(.headline)
Spacer()
if let best = testResults.map(\.scorePercent).max() {
Text("Best: \(best)%")
.font(.caption.weight(.semibold))
.foregroundStyle(.green)
}
}
ForEach(Array(testResults.sorted { $0.dateTaken > $1.dateTaken }.prefix(5).enumerated()), id: \.offset) { _, result in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Week \(result.weekNumber)")
.font(.subheadline.weight(.medium))
Text(result.dateTaken.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text("\(result.scorePercent)%")
.font(.title3.weight(.bold))
.foregroundStyle(result.scorePercent >= 90 ? .green : result.scorePercent >= 70 ? .orange : .red)
}
.padding(.vertical, 4)
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
// MARK: - Computed
private var recentLogs: [DailyLog] {
return dailyLogs
.filter { $0.reviewCount > 0 }
.suffix(30)
.reversed()
}
private var averageAccuracy: Int {
let logsWithData = dailyLogs.filter { $0.reviewCount > 0 }
guard !logsWithData.isEmpty else { return 0 }
let total = logsWithData.reduce(0.0) { $0 + $1.accuracy }
return Int(total / Double(logsWithData.count) * 100)
}
}
// MARK: - Stat Card
private struct StatCard: View {
let title: String
let value: String
let icon: String
let color: Color
var body: some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(color)
Text(value)
.font(.title2.bold().monospacedDigit())
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 14))
}
}
#Preview {
DashboardView()
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
}

View File

@@ -0,0 +1,81 @@
import SwiftUI
/// GitHub-style contribution heatmap showing daily activity over the last 90 days.
struct StreakCalendarView: View {
let dailyLogs: [DailyLog]
private let columns = Array(repeating: GridItem(.fixed(14), spacing: 2), count: 13)
private let dayLabels = ["M", "", "W", "", "F", "", ""]
private var last90Days: [DayData] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let logDict = Dictionary(uniqueKeysWithValues: dailyLogs.map { ($0.dateString, $0.reviewCount) })
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return (0..<91).reversed().map { offset in
let date = calendar.date(byAdding: .day, value: -offset, to: today)!
let dateStr = formatter.string(from: date)
let count = logDict[dateStr] ?? 0
return DayData(date: date, dateString: dateStr, reviewCount: count)
}
}
var body: some View {
HStack(alignment: .top, spacing: 4) {
// Day labels
VStack(spacing: 2) {
ForEach(Array(dayLabels.enumerated()), id: \.offset) { _, label in
Text(label)
.font(.system(size: 9))
.foregroundStyle(.secondary)
.frame(width: 14, height: 14)
}
}
// Calendar grid
LazyHGrid(rows: Array(repeating: GridItem(.fixed(14), spacing: 2), count: 7), spacing: 2) {
ForEach(last90Days, id: \.dateString) { day in
RoundedRectangle(cornerRadius: 2)
.fill(colorForCount(day.reviewCount))
.frame(width: 14, height: 14)
}
}
}
// Legend
HStack(spacing: 4) {
Text("Less")
.font(.system(size: 9))
.foregroundStyle(.secondary)
ForEach([0, 5, 15, 30, 50], id: \.self) { count in
RoundedRectangle(cornerRadius: 2)
.fill(colorForCount(count))
.frame(width: 12, height: 12)
}
Text("More")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
}
private func colorForCount(_ count: Int) -> Color {
switch count {
case 0: return .gray.opacity(0.15)
case 1...5: return .green.opacity(0.3)
case 6...15: return .green.opacity(0.5)
case 16...30: return .green.opacity(0.7)
default: return .green
}
}
}
private struct DayData {
let date: Date
let dateString: String
let reviewCount: Int
}

View File

@@ -0,0 +1,298 @@
import SwiftUI
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
struct GrammarNotesView: View {
@State private var selectedNote: GrammarNote?
var body: some View {
NavigationStack {
GrammarNotesListView(selectedNote: $selectedNote)
.navigationDestination(for: GrammarNote.self) { note in
GrammarNoteDetailView(note: note)
}
}
}
}
/// List of grammar notes that works inside a NavigationSplitView via a selection binding.
struct GrammarNotesListView: View {
@Binding var selectedNote: GrammarNote?
private var groupedNotes: [(String, [GrammarNote])] {
let grouped = Dictionary(grouping: GrammarNote.allNotes, by: \.category)
var seen: [String] = []
for note in GrammarNote.allNotes {
if !seen.contains(note.category) {
seen.append(note.category)
}
}
return seen.compactMap { cat in
guard let notes = grouped[cat] else { return nil }
return (cat, notes)
}
}
var body: some View {
List(selection: $selectedNote) {
ForEach(groupedNotes, id: \.0) { category, notes in
Section(category) {
ForEach(notes) { note in
NavigationLink(value: note) {
GrammarNoteRow(note: note)
}
}
}
}
}
}
}
// MARK: - Row
private struct GrammarNoteRow: View {
let note: GrammarNote
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(note.title)
.font(.headline)
Text(note.category)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Detail
struct GrammarNoteDetailView: View {
let note: GrammarNote
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Title header
VStack(alignment: .leading, spacing: 6) {
Text(note.title)
.font(.title.bold())
Text(note.category)
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
Divider()
// Parsed body
FormattedGrammarBody(content: note.body)
}
.padding()
.adaptiveContainer(maxWidth: 800)
}
.navigationTitle(note.title)
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Formatted Body
private struct FormattedGrammarBody: View {
let content: String
private var lines: [GrammarLine] {
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)
}
.padding(.leading, 12)
case .heading(let text):
Text(text)
.font(.headline)
.padding(.top, 6)
}
}
}
}
@ViewBuilder
private func renderParagraph(_ text: String) -> some View {
Text(parseInlineFormatting(text))
.font(.body)
.lineSpacing(4)
}
private func parseInlineFormatting(_ text: String) -> AttributedString {
var result = AttributedString()
var remaining = text[text.startIndex...]
while !remaining.isEmpty {
// Look for *italic* or **bold** markers
if let starRange = remaining.range(of: "*") {
// Add text before the star
let before = remaining[remaining.startIndex..<starRange.lowerBound]
if !before.isEmpty {
result.append(AttributedString(String(before)))
}
let afterStar = remaining[starRange.upperBound...]
// Check for **bold**
if afterStar.hasPrefix("*") {
let boldStart = afterStar.index(after: afterStar.startIndex)
if let endBold = afterStar[boldStart...].range(of: "**") {
let boldText = String(afterStar[boldStart..<endBold.lowerBound])
var attr = AttributedString(boldText)
attr.font = .body.bold()
result.append(attr)
remaining = afterStar[endBold.upperBound...]
continue
}
}
// Single *italic*
if let endItalic = afterStar.range(of: "*") {
let italicText = String(afterStar[afterStar.startIndex..<endItalic.lowerBound])
var attr = AttributedString(italicText)
attr.font = .body.italic()
result.append(attr)
remaining = afterStar[endItalic.upperBound...]
continue
}
// No closing star found, just add the star literally
result.append(AttributedString("*"))
remaining = afterStar
} else {
// No more stars, add the rest
result.append(AttributedString(String(remaining)))
break
}
}
return result
}
}
// MARK: - Grammar Line Parsing
private struct GrammarLine: Identifiable {
let id: Int
let kind: Kind
enum Kind {
case paragraph(String)
case heading(String)
case spanishExample(String)
case examplePair(spanish: String, english: String)
}
static func parse(_ body: String) -> [GrammarLine] {
let rawLines = body.components(separatedBy: "\n")
var result: [GrammarLine] = []
var index = 0
var paragraphBuffer = ""
func flushParagraph() {
let trimmed = paragraphBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
result.append(GrammarLine(id: result.count, kind: .paragraph(trimmed)))
}
paragraphBuffer = ""
}
while index < rawLines.count {
let line = rawLines[index].trimmingCharacters(in: .whitespaces)
// Empty line: flush paragraph
if line.isEmpty {
flushParagraph()
index += 1
continue
}
// Heading: **text** on its own line that doesn't contain example patterns
if line.hasPrefix("**") && line.hasSuffix("**") && !line.contains("*—*") && !line.contains("") {
flushParagraph()
let heading = line
.replacingOccurrences(of: "**", with: "")
.trimmingCharacters(in: .whitespaces)
result.append(GrammarLine(id: result.count, kind: .heading(heading)))
index += 1
continue
}
// Example line: starts with * and contains Spanish (italic example)
// Pattern: *Spanish sentence.* English translation.
if line.hasPrefix("*") && !line.hasPrefix("**") {
flushParagraph()
// Check for pattern: *spanish* english
if let dashRange = line.range(of: "") ?? line.range(of: " -- ") {
let spanishPart = String(line[line.startIndex..<dashRange.lowerBound])
.replacingOccurrences(of: "*", with: "")
.trimmingCharacters(in: .whitespaces)
let englishPart = String(line[dashRange.upperBound...])
.replacingOccurrences(of: "*", with: "")
.trimmingCharacters(in: .whitespaces)
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: "")
.trimmingCharacters(in: .whitespaces)
result.append(GrammarLine(id: result.count, kind: .spanishExample(spanish)))
}
index += 1
continue
}
// Regular text: accumulate into paragraph
if !paragraphBuffer.isEmpty {
paragraphBuffer += " "
}
paragraphBuffer += line
index += 1
}
flushParagraph()
return result
}
}
// MARK: - Hashable/Equatable conformance for NavigationLink
extension GrammarNote: Hashable, Equatable {
static func == (lhs: GrammarNote, rhs: GrammarNote) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
#Preview {
NavigationStack {
GrammarNotesView()
}
}

View File

@@ -0,0 +1,544 @@
import SwiftUI
import SwiftData
struct GuideView: View {
@Environment(\.modelContext) private var modelContext
@State private var guides: [TenseGuide] = []
@State private var selectedGuide: TenseGuide?
@State private var selectedNote: GrammarNote?
@State private var selectedTab: GuideTab = .tenses
enum GuideTab: String, CaseIterable {
case tenses = "Tenses"
case grammar = "Grammar"
}
private var guideMap: [String: TenseGuide] {
Dictionary(uniqueKeysWithValues: guides.map { ($0.tenseId, $0) })
}
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
Picker("Guide", selection: $selectedTab) {
ForEach(GuideTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)
switch selectedTab {
case .tenses:
tensesList
case .grammar:
grammarList
}
}
.navigationTitle("Guide")
.onAppear(perform: loadGuides)
.onChange(of: selectedTab) { _, _ in
selectedGuide = nil
selectedNote = nil
}
} detail: {
if let guide = selectedGuide {
GuideDetailView(guide: guide)
} else if let note = selectedNote {
GrammarNoteDetailView(note: note)
} else {
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more."))
}
}
}
private var tensesList: some View {
List(selection: $selectedGuide) {
ForEach(TenseInfo.byMood(), id: \.0) { mood, tenses in
Section(mood) {
ForEach(tenses) { tense in
if let guide = guideMap[tense.id] {
NavigationLink(value: guide) {
TenseRowView(tense: tense)
}
} else {
TenseRowView(tense: tense)
.foregroundStyle(.tertiary)
}
}
}
}
}
}
private var grammarList: some View {
GrammarNotesListView(selectedNote: $selectedNote)
}
private func loadGuides() {
guides = ReferenceStore(context: modelContext).fetchGuides()
if selectedGuide == nil {
selectedGuide = guides.first
}
}
}
// MARK: - Tense Row
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)
}
}
}
// MARK: - Guide Detail
struct GuideDetailView: View {
let guide: TenseGuide
private var tenseInfo: TenseInfo? {
TenseInfo.find(guide.tenseId)
}
private var endingTable: TenseEndingTable? {
TenseEndingTable.find(guide.tenseId)
}
private var parsedContent: GuideContent {
GuideContent.parse(guide.body)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Header
headerSection
// Conjugation ending table
if let table = endingTable {
conjugationTableSection(table)
}
// Formula
if let table = endingTable {
formulaSection(table)
}
Divider()
// Description
if !parsedContent.description.isEmpty {
descriptionSection
}
// Consolidated usages card
if !parsedContent.usages.isEmpty {
usagesSummarySection
}
// Examples section
if parsedContent.usages.contains(where: { !$0.examples.isEmpty }) {
examplesSection
}
}
.padding()
.adaptiveContainer(maxWidth: 800)
}
.navigationTitle(tenseInfo?.english ?? guide.title)
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Header
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
if let info = tenseInfo {
Text(info.spanish)
.font(.title2.weight(.bold))
Text(info.english)
.font(.title3)
.foregroundStyle(.secondary)
HStack {
Label(info.mood, systemImage: "tag")
.font(.caption.weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
}
}
// MARK: - Conjugation Table
private func conjugationTableSection(_ table: TenseEndingTable) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Conjugation Endings")
.font(.headline)
if table.isCompound, let aux = table.auxiliaryForms {
// Compound tense: show auxiliary + participle
compoundTenseTable(table, auxiliary: aux)
} else {
// Simple tense: show endings grid
simpleEndingsGrid(table)
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
private func simpleEndingsGrid(_ table: TenseEndingTable) -> some View {
VStack(spacing: 0) {
// Header row
HStack(spacing: 0) {
Text("")
.frame(width: 80, alignment: .leading)
Text("~ar")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
.frame(maxWidth: .infinity)
Text("~er")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.green)
.frame(maxWidth: .infinity)
Text("~ir")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.blue)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 6)
.background(.fill.quaternary)
// Example verb names
HStack(spacing: 0) {
Text("")
.frame(width: 80, alignment: .leading)
Text(table.arExample.verb)
.font(.caption2.italic())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
Text(table.erExample.verb)
.font(.caption2.italic())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
Text(table.irExample.verb)
.font(.caption2.italic())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 4)
Divider()
// Person rows
ForEach(0..<6, id: \.self) { i in
HStack(spacing: 0) {
Text(TenseEndingTable.persons[i])
.font(.subheadline.weight(.medium))
.frame(width: 80, alignment: .leading)
endingCell(table.arExample.stem, ending: table.arEndings[i], color: .orange)
.frame(maxWidth: .infinity)
endingCell(table.erExample.stem, ending: table.erEndings[i], color: .green)
.frame(maxWidth: .infinity)
endingCell(table.irExample.stem, ending: table.irEndings[i], color: .blue)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 5)
if i < 5 {
Divider().opacity(0.5)
}
}
}
}
private func endingCell(_ stem: String, ending: String, color: Color) -> some View {
Group {
if ending == "" {
Text("")
.font(.subheadline)
.foregroundStyle(.tertiary)
} else {
Text(
"\(Text(stem).foregroundStyle(.primary))\(Text(ending).foregroundStyle(color).fontWeight(.semibold))"
)
.font(.subheadline)
}
}
}
private func compoundTenseTable(_ table: TenseEndingTable, auxiliary: [String]) -> some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 0) {
Text("")
.frame(width: 80, alignment: .leading)
Text("Auxiliary")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.purple)
.frame(maxWidth: .infinity)
Text("~ar")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
.frame(maxWidth: .infinity)
Text("~er/~ir")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.cyan)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 6)
.background(.fill.quaternary)
Divider()
ForEach(0..<6, id: \.self) { i in
HStack(spacing: 0) {
Text(TenseEndingTable.persons[i])
.font(.subheadline.weight(.medium))
.frame(width: 80, alignment: .leading)
Text(auxiliary[i])
.font(.subheadline)
.foregroundStyle(.purple)
.frame(maxWidth: .infinity)
Text(table.arExample.stem + "ado")
.font(.subheadline)
.foregroundStyle(.orange)
.frame(maxWidth: .infinity)
Text(table.erExample.stem + "ido")
.font(.subheadline)
.foregroundStyle(.cyan)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 5)
if i < 5 {
Divider().opacity(0.5)
}
}
}
}
// MARK: - Formula
private func formulaSection(_ table: TenseEndingTable) -> some View {
VStack(alignment: .leading, spacing: 6) {
Label("How to form", systemImage: "function")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Text(table.formula)
.font(.body.weight(.medium))
Text(table.stemRule)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Description
private var descriptionSection: some View {
Text(parsedContent.description)
.font(.body)
.lineSpacing(4)
}
// MARK: - Usages Summary (consolidated)
private var usagesSummarySection: some View {
VStack(alignment: .leading, spacing: 10) {
Label("When to use", systemImage: "lightbulb")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(parsedContent.usages) { usage in
HStack(alignment: .top, spacing: 10) {
Text("\(usage.number).")
.font(.subheadline.weight(.bold))
.foregroundStyle(.blue)
.frame(width: 20, alignment: .trailing)
Text(usage.title)
.font(.subheadline)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Examples Section
private var examplesSection: some View {
VStack(alignment: .leading, spacing: 16) {
Label("Examples", systemImage: "text.quote")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(parsedContent.usages) { usage in
ForEach(usage.examples) { example in
VStack(alignment: .leading, spacing: 4) {
Text(example.spanish)
.font(.body.weight(.medium))
.italic()
Text(example.english)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
}
}
}
// MARK: - Content Parsing
struct GuideContent {
let description: String
let usages: [GuideUsage]
static func parse(_ body: String) -> GuideContent {
let lines = body.components(separatedBy: "\n")
var description = ""
var usages: [GuideUsage] = []
var currentUsageTitle = ""
var currentUsageNumber = 0
var currentExamples: [GuideExample] = []
var inUsages = false
var spanishLine: String?
func flushUsage() {
if currentUsageNumber > 0 {
usages.append(GuideUsage(
number: currentUsageNumber,
title: currentUsageTitle,
examples: currentExamples
))
currentExamples = []
}
}
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Skip empty lines
if trimmed.isEmpty {
if let sp = spanishLine {
// Spanish line with no English translation
currentExamples.append(GuideExample(spanish: sp, english: ""))
spanishLine = nil
}
continue
}
// Detect usage headers like "*1 Current actions*" or "**1 Current actions**"
let usagePattern = /^\*{1,2}(\d+)\s+(.+?)\*{0,2}$/
if let match = trimmed.firstMatch(of: usagePattern) {
flushUsage()
inUsages = true
currentUsageNumber = Int(match.1) ?? 0
currentUsageTitle = String(match.2)
.replacingOccurrences(of: "**", with: "")
.replacingOccurrences(of: "*", with: "")
continue
}
// Detect usage headers without numbers
let usageTitlePattern = /^\*{1,2}(.+?)\*{0,2}$/
if !inUsages, let match = trimmed.firstMatch(of: usageTitlePattern) {
let title = String(match.1).replacingOccurrences(of: "*", with: "")
if title.lowercased().contains("usage") {
inUsages = true
continue
}
}
if !inUsages {
// Before usages section, accumulate description
let cleanLine = trimmed
.replacingOccurrences(of: "**", with: "")
.replacingOccurrences(of: "*", with: "")
if !description.isEmpty { description += " " }
description += cleanLine
continue
}
// In usages: detect example pairs (Spanish line followed by English line)
if let sp = spanishLine {
// This line is the English translation of the previous Spanish line
currentExamples.append(GuideExample(spanish: sp, english: trimmed))
spanishLine = nil
} else {
// Check if this looks like a Spanish sentence
let startsWithDash = trimmed.hasPrefix("-") || trimmed.hasPrefix("")
let cleanLine = startsWithDash ? String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces) : trimmed
// Heuristic: Spanish sentences contain accented chars or start with ¿/¡
let isSpanish = cleanLine.contains("á") || cleanLine.contains("é") || cleanLine.contains("í") ||
cleanLine.contains("ó") || cleanLine.contains("ú") || cleanLine.contains("ñ") ||
cleanLine.hasPrefix("¿") || cleanLine.hasPrefix("¡") ||
cleanLine.first?.isUppercase == true
if isSpanish && !cleanLine.lowercased().hasPrefix("i ") && !cleanLine.lowercased().hasPrefix("the ") &&
!cleanLine.lowercased().hasPrefix("all ") && !cleanLine.lowercased().hasPrefix("when ") &&
!cleanLine.lowercased().hasPrefix("what ") {
spanishLine = cleanLine
} else {
// English or context line
if currentUsageNumber == 0 && usages.isEmpty {
// Still description
if !description.isEmpty { description += " " }
description += cleanLine
}
}
}
}
flushUsage()
// If no usages were found, create one from the whole body
if usages.isEmpty && !body.isEmpty {
usages.append(GuideUsage(number: 1, title: "Usage", examples: []))
}
return GuideContent(description: description, usages: usages)
}
}
struct GuideUsage: Identifiable {
let number: Int
let title: String
let examples: [GuideExample]
var id: Int { number }
}
struct GuideExample: Identifiable {
let spanish: String
let english: String
var id: String { spanish }
}
#Preview {
GuideView()
.modelContainer(for: TenseGuide.self, inMemory: true)
}

View File

@@ -0,0 +1,30 @@
import SwiftUI
struct MainTabView: View {
var body: some View {
TabView {
Tab("Dashboard", systemImage: "chart.bar") {
DashboardView()
}
Tab("Practice", systemImage: "brain.head.profile") {
PracticeView()
}
Tab("Verbs", systemImage: "textformat.abc") {
VerbListView()
}
Tab("Guide", systemImage: "book") {
GuideView()
}
Tab("Course", systemImage: "list.clipboard") {
CourseView()
}
Tab("Settings", systemImage: "gearshape") {
SettingsView()
}
}
}
}
#Preview {
MainTabView()
}

View File

@@ -0,0 +1,141 @@
import SwiftUI
import SwiftData
struct OnboardingView: View {
@Environment(\.modelContext) private var modelContext
@AppStorage("onboardingComplete") private var onboardingComplete = false
@State private var currentPage = 0
@State private var selectedLevel: VerbLevel = .basic
private let levels = VerbLevel.allCases
var body: some View {
TabView(selection: $currentPage) {
// Page 1: Welcome
VStack(spacing: 24) {
Spacer()
Image(systemName: "character.book.closed.fill")
.font(.system(size: 80))
.foregroundStyle(.blue)
Text("Welcome to Conjuga")
.font(.largeTitle)
.fontWeight(.bold)
Text("Master Spanish verb conjugations\nwith spaced repetition.")
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer()
Button("Get Started") {
withAnimation {
currentPage = 1
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.bottom, 48)
}
.padding()
.tag(0)
// Page 2: Level selection
VStack(spacing: 24) {
Spacer()
Text("Choose Your Level")
.font(.title)
.fontWeight(.bold)
Text("You can change this later in Settings.")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(spacing: 12) {
ForEach(levels, id: \.self) { level in
Button {
selectedLevel = level
} label: {
HStack {
Text(level.displayName)
.font(.headline)
Spacer()
if selectedLevel == level {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.blue)
}
}
.padding()
.background(selectedLevel == level ? Color.blue.opacity(0.1) : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.tint(.primary)
}
}
.padding(.horizontal)
Spacer()
Button("Continue") {
withAnimation {
currentPage = 2
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.bottom, 48)
}
.padding()
.tag(1)
// Page 3: Ready
VStack(spacing: 24) {
Spacer()
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 80))
.foregroundStyle(.green)
Text("You're All Set!")
.font(.largeTitle)
.fontWeight(.bold)
Text("Start practicing with the most common\nSpanish verbs at your level.")
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer()
Button("Start Practicing") {
completeOnboarding()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.bottom, 48)
}
.padding()
.tag(2)
}
.tabViewStyle(.page)
.indexViewStyle(.page(backgroundDisplayMode: .always))
}
private func completeOnboarding() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: modelContext)
progress.selectedVerbLevel = selectedLevel
if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
}
try? modelContext.save()
onboardingComplete = true
}
}
#Preview {
OnboardingView()
.modelContainer(for: UserProgress.self, inMemory: true)
}

View File

@@ -0,0 +1,139 @@
import SwiftUI
struct AnswerReviewView: View {
let form: VerbForm?
let spans: [IrregularSpan]
let speechService: SpeechService
let onRate: (ReviewQuality) -> Void
var showAnswer: Bool = true
var onPrevious: (() -> Void)? = nil
var onNext: (() -> Void)? = nil
init(
form: VerbForm?,
spans: [IrregularSpan] = [],
speechService: SpeechService,
onRate: @escaping (ReviewQuality) -> Void,
showAnswer: Bool = true,
onPrevious: (() -> Void)? = nil,
onNext: (() -> Void)? = nil
) {
self.form = form
self.spans = spans
self.speechService = speechService
self.onRate = onRate
self.showAnswer = showAnswer
self.onPrevious = onPrevious
self.onNext = onNext
}
var body: some View {
VStack(spacing: 20) {
// Correct form display with irregular highlighting
if showAnswer, let form {
VStack(spacing: 8) {
Text("Correct Answer")
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
IrregularHighlightText(
form: form.form,
spans: spans,
font: .title.bold()
)
}
// Pronunciation button
Button {
speechService.speak(form.form)
} label: {
Label("Listen", systemImage: "speaker.wave.2.fill")
.font(.subheadline)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.glassEffect(in: .capsule)
// Regularity badge
if form.regularity != "regular" {
Text(form.regularity.capitalized)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.quaternary, in: .capsule)
}
Divider()
.padding(.horizontal)
}
// Rating buttons
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
RatingButton(label: "Again", color: .red, quality: .again, action: onRate)
RatingButton(label: "Hard", color: .orange, quality: .hard, action: onRate)
RatingButton(label: "Good", color: .green, quality: .good, action: onRate)
RatingButton(label: "Easy", color: .blue, quality: .easy, action: onRate)
}
.padding(.horizontal)
// Navigation arrows
HStack {
if let onPrevious {
Button { onPrevious() } label: {
Label("Previous", systemImage: "chevron.left")
.font(.subheadline)
}
.tint(.secondary)
}
Spacer()
if let onNext {
Button { onNext() } label: {
Label("Skip", systemImage: "chevron.right")
.font(.subheadline)
.labelStyle(.titleAndIcon)
}
.tint(.secondary)
}
}
.padding(.horizontal)
}
.padding(.vertical)
}
}
private struct RatingButton: View {
let label: String
let color: Color
let quality: ReviewQuality
let action: (ReviewQuality) -> Void
var body: some View {
Button {
action(quality)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
}
#Preview {
AnswerReviewView(
form: nil,
speechService: SpeechService(),
onRate: { _ in }
)
.padding()
}

View File

@@ -0,0 +1,98 @@
import SwiftUI
import SwiftData
struct FlashcardView: View {
@Environment(\.modelContext) private var modelContext
var viewModel: PracticeViewModel
let speechService: SpeechService
var body: some View {
ScrollView {
VStack(spacing: 24) {
PracticeHeaderView(
verb: viewModel.currentVerb,
tenseInfo: viewModel.currentTenseInfo,
person: viewModel.currentPerson
)
// Flashcard area
VStack(spacing: 16) {
if viewModel.isAnswerRevealed {
VStack(spacing: 12) {
if let form = viewModel.currentForm {
IrregularHighlightText(
form: form.form,
spans: viewModel.currentSpans,
font: .largeTitle.bold()
)
Button {
speechService.speak(form.form)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title3)
.padding(12)
}
.glassEffect(in: .circle)
}
}
.transition(.blurReplace)
} else {
VStack(spacing: 8) {
Image(systemName: "hand.tap")
.font(.title)
.foregroundStyle(.secondary)
Text("Tap to reveal")
.font(.headline)
.foregroundStyle(.secondary)
}
.transition(.blurReplace)
}
}
.frame(maxWidth: .infinity)
.frame(minHeight: 180)
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
.overlay {
if !viewModel.isAnswerRevealed {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.smooth) {
viewModel.revealAnswer()
}
}
}
}
// Rating buttons (no duplicate answer)
if viewModel.isAnswerRevealed {
AnswerReviewView(
form: viewModel.currentForm,
spans: viewModel.currentSpans,
speechService: speechService,
onRate: { quality in
viewModel.rateAnswer(quality: quality, context: modelContext)
viewModel.loadNextCard(context: modelContext)
},
showAnswer: false,
onNext: {
viewModel.loadNextCard(context: modelContext)
}
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.padding()
.adaptiveContainer()
}
.animation(.smooth, value: viewModel.isAnswerRevealed)
}
}
#Preview {
NavigationStack {
FlashcardView(viewModel: PracticeViewModel(), speechService: SpeechService())
}
.modelContainer(for: Verb.self, inMemory: true)
}

View File

@@ -0,0 +1,352 @@
import SwiftUI
import SwiftData
import PencilKit
/// Practice mode where user fills in all 6 person conjugations for a verb + tense.
struct FullTableView: View {
@Environment(\.modelContext) private var modelContext
let speechService: SpeechService
@State private var currentVerb: Verb?
@State private var currentTense: TenseInfo?
@State private var correctForms: [VerbForm] = []
@State private var userAnswers: [String] = Array(repeating: "", count: 6)
@State private var results: [Bool?] = Array(repeating: nil, count: 6)
@State private var isChecked = false
@State private var showVosotros = true
@State private var autoFillStem = false
@State private var useHandwriting = false
@State private var sessionCount = 0
@State private var sessionCorrect = 0
// Handwriting state per field
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
@State private var activeHWField: Int?
@State private var isRecognizing = false
@FocusState private var focusedField: Int?
private let persons = TenseInfo.persons
private var personsToShow: [(index: Int, label: String)] {
persons.enumerated().compactMap { index, label in
if !showVosotros && index == 4 { return nil }
return (index, label)
}
}
/// Get the stem of the current verb (infinitive minus ending)
private var verbStem: String {
guard let verb = currentVerb else { return "" }
let inf = verb.infinitive.lowercased()
if inf.hasSuffix("ar") || inf.hasSuffix("er") || inf.hasSuffix("ir") {
return String(inf.dropLast(2))
}
if inf.hasSuffix("ír") {
return String(inf.dropLast(2))
}
return inf
}
var body: some View {
ScrollView {
VStack(spacing: 32) {
// Header
if let verb = currentVerb, let tense = currentTense {
headerSection(verb: verb, tense: tense)
}
// Input mode toggle
HStack {
Picker("Input", selection: $useHandwriting) {
Label("Keyboard", systemImage: "keyboard").tag(false)
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
// Input fields
inputSection
// Check / Next button
actionButton
// Score
if sessionCount > 0 {
scoreSection
}
}
.padding()
.adaptiveContainer()
}
.navigationTitle("Full Table")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
loadSettings()
loadNextVerb()
}
}
// MARK: - Header
private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
VStack(spacing: 8) {
Text(verb.infinitive)
.font(.largeTitle.weight(.bold))
Button {
speechService.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2")
.font(.title3)
}
.tint(.secondary)
Text(verb.english)
.font(.title3)
.foregroundStyle(.secondary)
TensePill(tenseInfo: tense)
}
.padding(.top, 8)
}
// MARK: - Input Fields
private var inputSection: some View {
VStack(spacing: 16) {
ForEach(personsToShow, id: \.index) { index, label in
VStack(spacing: 4) {
HStack(spacing: 12) {
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
.frame(minWidth: 90, alignment: .trailing)
if useHandwriting {
// Handwriting canvas per field
ZStack(alignment: .trailing) {
VStack(spacing: 0) {
if autoFillStem {
Text(verbStem)
.font(.caption)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .leading)
}
HandwritingCanvas(drawing: $drawings[index])
.frame(height: 60)
.disabled(isChecked)
}
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(dividerColor(for: index), lineWidth: 1)
)
if let result = results[index] {
Image(systemName: result ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(result ? .green : .red)
.font(.title3)
.padding(.trailing, 8)
}
}
} else {
// Keyboard text field
ZStack(alignment: .trailing) {
TextField(autoFillStem ? verbStem : "", text: $userAnswers[index])
.font(.title3)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: index)
.disabled(isChecked)
.onSubmit { advanceFocus(from: index) }
.foregroundStyle(foregroundColor(for: index))
if let result = results[index] {
Image(systemName: result ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(result ? .green : .red)
.font(.title3)
}
}
Divider()
.frame(height: 1)
.overlay(dividerColor(for: index))
}
}
// Show correct answer if wrong
if isChecked, let result = results[index], !result {
HStack {
Spacer()
.frame(minWidth: 102)
Text(correctForms.first(where: { $0.personIndex == index })?.form ?? "")
.font(.callout.weight(.medium))
.foregroundStyle(.green)
}
}
}
}
}
.padding()
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
}
// MARK: - Action Button
private var actionButton: some View {
Button {
if isChecked {
loadNextVerb()
} else {
if useHandwriting {
recognizeAndCheck()
} else {
checkAnswers()
}
}
} label: {
HStack {
if isRecognizing {
ProgressView().tint(.white)
}
Text(isChecked ? "Next" : "Check")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(isChecked ? .blue : .orange)
.disabled(isRecognizing)
}
// MARK: - Score
private var scoreSection: some View {
HStack(spacing: 20) {
Label("\(sessionCount) reviewed", systemImage: "square.stack")
Label("\(sessionCorrect) perfect", systemImage: "checkmark.seal")
}
.font(.caption)
.foregroundStyle(.secondary)
}
// MARK: - Logic
private func loadNextVerb() {
isChecked = false
results = Array(repeating: nil, count: 6)
correctForms = []
drawings = Array(repeating: PKDrawing(), count: 6)
let service = PracticeSessionService(context: modelContext)
guard let prompt = service.randomFullTablePrompt() else {
currentVerb = nil
currentTense = nil
userAnswers = Array(repeating: "", count: 6)
focusedField = nil
return
}
currentVerb = prompt.verb
currentTense = prompt.tenseInfo
correctForms = prompt.forms
// Auto-fill stems if enabled
if autoFillStem {
let stem = verbStem
userAnswers = Array(repeating: stem, count: 6)
} else {
userAnswers = Array(repeating: "", count: 6)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
if !useHandwriting {
focusedField = personsToShow.first?.index ?? 0
}
}
}
private func recognizeAndCheck() {
isRecognizing = true
Task {
for person in personsToShow {
let idx = person.index
let result = await HandwritingRecognizer.recognize(drawing: drawings[idx])
var recognized = result.text
// Prepend stem if auto-fill is on
if autoFillStem && !recognized.isEmpty {
recognized = verbStem + recognized
}
await MainActor.run {
userAnswers[idx] = recognized
}
}
await MainActor.run {
isRecognizing = false
checkAnswers()
}
}
}
private func checkAnswers() {
var allCorrect = true
for person in personsToShow {
let index = person.index
let userAnswer = userAnswers[index].trimmingCharacters(in: .whitespacesAndNewlines)
let correctForm = correctForms.first(where: { $0.personIndex == index })?.form ?? ""
let isCorrect = userAnswer.compare(correctForm, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
results[index] = isCorrect
if !isCorrect { allCorrect = false }
}
isChecked = true
sessionCount += 1
if allCorrect { sessionCorrect += 1 }
if let verb = currentVerb, let tense = currentTense {
let service = PracticeSessionService(context: modelContext)
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
}
focusedField = nil
}
private func advanceFocus(from index: Int) {
let remaining = personsToShow.filter { $0.index > index }
if let next = remaining.first {
focusedField = next.index
} else {
focusedField = nil
if !isChecked { checkAnswers() }
}
}
private func loadSettings() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: modelContext)
showVosotros = progress.showVosotros
autoFillStem = progress.autoFillStem
}
private func foregroundColor(for index: Int) -> Color {
guard let result = results[index] else { return .primary }
return result ? .green : .red
}
private func dividerColor(for index: Int) -> Color {
guard let result = results[index] else { return .secondary.opacity(0.3) }
return result ? .green : .red
}
}
#Preview {
NavigationStack {
FullTableView(speechService: SpeechService())
}
.modelContainer(for: [Verb.self, VerbForm.self, ReviewCard.self, UserProgress.self], inMemory: true)
}

View File

@@ -0,0 +1,153 @@
import SwiftUI
import SwiftData
import PencilKit
struct HandwritingView: View {
@Environment(\.modelContext) private var modelContext
var viewModel: PracticeViewModel
let speechService: SpeechService
@State private var drawing = PKDrawing()
@State private var recognizedText = ""
@State private var isRecognizing = false
var body: some View {
ScrollView {
VStack(spacing: 20) {
PracticeHeaderView(
verb: viewModel.currentVerb,
tenseInfo: viewModel.currentTenseInfo,
person: viewModel.currentPerson
)
if viewModel.isAnswerRevealed {
// Result
VStack(spacing: 12) {
if viewModel.isCorrect == true {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 44))
.foregroundStyle(.green)
Text("Correct!")
.font(.title3.bold())
.foregroundStyle(.green)
} else {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 44))
.foregroundStyle(.red)
if !recognizedText.isEmpty {
Text("Recognized: \"\(recognizedText)\"")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.transition(.blurReplace)
// Answer review + navigation
AnswerReviewView(
form: viewModel.currentForm,
spans: viewModel.currentSpans,
speechService: speechService,
onRate: { quality in
viewModel.rateAnswer(quality: quality, context: modelContext)
viewModel.loadNextCard(context: modelContext)
resetCanvas()
},
onNext: {
viewModel.loadNextCard(context: modelContext)
resetCanvas()
}
)
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
// Canvas
VStack(spacing: 12) {
Text("Write the conjugation")
.font(.subheadline)
.foregroundStyle(.secondary)
HandwritingCanvas(drawing: $drawing)
.frame(height: 200)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(.separator, lineWidth: 1)
)
// Recognized text preview
if !recognizedText.isEmpty {
Text("Recognized: \"\(recognizedText)\"")
.font(.caption)
.foregroundStyle(.secondary)
.transition(.opacity)
}
HStack(spacing: 16) {
Button {
resetCanvas()
} label: {
Label("Clear", systemImage: "trash")
.font(.subheadline)
}
.tint(.secondary)
Button {
checkHandwriting()
} label: {
HStack {
if isRecognizing {
ProgressView()
.tint(.white)
}
Text("Check")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.disabled(drawing.strokes.isEmpty || isRecognizing)
}
}
.padding(.horizontal)
}
}
.padding()
.adaptiveContainer()
}
.animation(.smooth, value: viewModel.isAnswerRevealed)
}
private func checkHandwriting() {
isRecognizing = true
Task {
let result = await HandwritingRecognizer.recognize(drawing: drawing)
await MainActor.run {
recognizedText = result.text
isRecognizing = false
let correct = viewModel.currentForm?.form ?? ""
let isMatch = HandwritingRecognizer.checkAnswer(result: result, expected: correct)
viewModel.isCorrect = isMatch
viewModel.isAnswerRevealed = true
if isMatch {
viewModel.sessionCorrect += 1
}
viewModel.sessionTotal += 1
}
}
}
private func resetCanvas() {
drawing = PKDrawing()
recognizedText = ""
}
}
#Preview {
NavigationStack {
HandwritingView(viewModel: PracticeViewModel(), speechService: SpeechService())
}
.modelContainer(for: Verb.self, inMemory: true)
}

View File

@@ -0,0 +1,129 @@
import SwiftUI
import SwiftData
struct MultipleChoiceView: View {
@Environment(\.modelContext) private var modelContext
var viewModel: PracticeViewModel
let speechService: SpeechService
@State private var selectedIndex: Int?
var body: some View {
ScrollView {
VStack(spacing: 24) {
PracticeHeaderView(
verb: viewModel.currentVerb,
tenseInfo: viewModel.currentTenseInfo,
person: viewModel.currentPerson
)
// Choice buttons
VStack(spacing: 12) {
ForEach(Array(viewModel.multipleChoiceOptions.enumerated()), id: \.offset) { index, option in
ChoiceButton(
text: option,
state: choiceState(for: index, option: option),
action: {
guard !viewModel.isAnswerRevealed else { return }
selectedIndex = index
withAnimation(.smooth) {
_ = viewModel.checkAnswer(option)
}
}
)
}
}
.padding(.horizontal)
// Answer review
if viewModel.isAnswerRevealed {
AnswerReviewView(
form: viewModel.currentForm,
spans: viewModel.currentSpans,
speechService: speechService,
onRate: { quality in
viewModel.rateAnswer(quality: quality, context: modelContext)
selectedIndex = nil
viewModel.loadNextCard(context: modelContext)
},
onNext: {
selectedIndex = nil
viewModel.loadNextCard(context: modelContext)
}
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.padding()
.adaptiveContainer()
}
.animation(.smooth, value: viewModel.isAnswerRevealed)
}
private func choiceState(for index: Int, option: String) -> ChoiceButton.ButtonState {
guard viewModel.isAnswerRevealed else { return .idle }
let correctForm = viewModel.currentForm?.form ?? ""
if option == correctForm {
return .correct
} else if index == selectedIndex {
return .incorrect
}
return .dimmed
}
}
private struct ChoiceButton: View {
let text: String
let state: ButtonState
let action: () -> Void
enum ButtonState {
case idle, correct, incorrect, dimmed
}
private var tintColor: Color {
switch state {
case .idle: .primary
case .correct: .green
case .incorrect: .red
case .dimmed: .secondary
}
}
private var symbolName: String? {
switch state {
case .correct: "checkmark.circle.fill"
case .incorrect: "xmark.circle.fill"
default: nil
}
}
var body: some View {
Button(action: action) {
HStack {
Text(text)
.font(.title3.weight(.medium))
Spacer()
if let symbolName {
Image(systemName: symbolName)
.font(.title3)
.foregroundStyle(tintColor)
.transition(.scale.combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
.tint(tintColor)
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
.opacity(state == .dimmed ? 0.5 : 1.0)
.disabled(state != .idle)
}
}
#Preview {
NavigationStack {
MultipleChoiceView(viewModel: PracticeViewModel(), speechService: SpeechService())
}
.modelContainer(for: Verb.self, inMemory: true)
}

View File

@@ -0,0 +1,37 @@
import SwiftUI
struct PracticeHeaderView: View {
let verb: Verb?
let tenseInfo: TenseInfo?
let person: String
var body: some View {
VStack(spacing: 12) {
if let verb {
Text(verb.infinitive)
.font(.title2.bold())
Text(verb.english)
.font(.subheadline)
.foregroundStyle(.secondary)
}
if let tenseInfo {
TensePill(tenseInfo: tenseInfo)
}
Text(person)
.font(.title3.weight(.medium))
.foregroundStyle(.primary)
}
}
}
#Preview {
PracticeHeaderView(
verb: nil,
tenseInfo: TenseInfo.all.first,
person: "yo"
)
.padding()
}

View File

@@ -0,0 +1,322 @@
import SwiftUI
import SwiftData
struct PracticeView: View {
@Environment(\.modelContext) private var modelContext
@Query private var progress: [UserProgress]
@State private var viewModel = PracticeViewModel()
@State private var speechService = SpeechService()
@State private var isPracticing = false
private var userProgress: UserProgress? {
progress.first
}
var body: some View {
NavigationStack {
Group {
if isPracticing {
practiceSessionView
} else {
practiceHomeView
}
}
.navigationTitle("Practice")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if isPracticing {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
withAnimation {
isPracticing = false
}
}
}
ToolbarItem(placement: .primaryAction) {
sessionStatsLabel
}
}
}
}
}
// MARK: - Home View
private var practiceHomeView: some View {
ScrollView {
VStack(spacing: 28) {
// Daily progress
if let progress = userProgress {
VStack(spacing: 8) {
DailyProgressRing(
current: progress.todayCount,
goal: progress.dailyGoal
)
.frame(width: 160, height: 160)
Text("\(progress.todayCount) / \(progress.dailyGoal)")
.font(.title3.weight(.semibold))
.foregroundStyle(.secondary)
if progress.currentStreak > 0 {
Label("\(progress.currentStreak) day streak", systemImage: "flame.fill")
.font(.subheadline.weight(.medium))
.foregroundStyle(.orange)
}
}
.padding(.top, 8)
}
// Mode selection
VStack(spacing: 12) {
Text("Choose a Mode")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(PracticeMode.allCases) { mode in
ModeButton(mode: mode) {
viewModel.practiceMode = mode
viewModel.focusMode = .none
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(context: modelContext)
withAnimation {
isPracticing = true
}
}
}
}
.padding(.horizontal)
// Quick Actions
VStack(spacing: 12) {
Text("Quick Actions")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
// Weak verbs focus
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .weakVerbs
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(context: modelContext)
withAnimation { isPracticing = true }
} label: {
HStack(spacing: 14) {
Image(systemName: "exclamationmark.triangle")
.font(.title3)
.foregroundStyle(.red)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Weak Verbs")
.font(.subheadline.weight(.semibold))
Text("Focus on verbs you struggle with")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Irregularity drills
Menu {
Button("Spelling Changes (c→qu, z→c, ...)") {
startIrregularityDrill(.spelling)
}
Button("Stem Changes (o→ue, e→ie, ...)") {
startIrregularityDrill(.stemChange)
}
Button("Unique Irregulars (ser, ir, ...)") {
startIrregularityDrill(.uniqueIrregular)
}
} label: {
HStack(spacing: 14) {
Image(systemName: "wand.and.stars")
.font(.title3)
.foregroundStyle(.purple)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Irregularity Drills")
.font(.subheadline.weight(.semibold))
Text("Practice by irregularity type")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
// Session stats summary
if viewModel.sessionTotal > 0 && !isPracticing {
VStack(spacing: 8) {
Text("Last Session")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 20) {
StatItem(label: "Reviewed", value: "\(viewModel.sessionTotal)")
StatItem(label: "Correct", value: "\(viewModel.sessionCorrect)")
StatItem(
label: "Accuracy",
value: "\(Int(viewModel.sessionAccuracy * 100))%"
)
}
.padding()
.frame(maxWidth: .infinity)
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
}
.padding(.horizontal)
}
}
.padding(.vertical)
.adaptiveContainer()
}
}
// MARK: - Practice Session View
@ViewBuilder
private var practiceSessionView: some View {
if !viewModel.hasCards {
ContentUnavailableView(
"No Cards Available",
systemImage: "rectangle.on.rectangle.slash",
description: Text("Add some verbs to your practice deck to get started.")
)
} else {
switch viewModel.practiceMode {
case .flashcard:
FlashcardView(viewModel: viewModel, speechService: speechService)
case .typing:
TypingView(viewModel: viewModel, speechService: speechService)
case .multipleChoice:
MultipleChoiceView(viewModel: viewModel, speechService: speechService)
case .fullTable:
FullTableView(speechService: speechService)
case .handwriting:
HandwritingView(viewModel: viewModel, speechService: speechService)
case .sentenceBuilder:
SentenceBuilderView()
}
}
}
// MARK: - Session Stats Label
private var sessionStatsLabel: some View {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(viewModel.sessionCorrect)/\(viewModel.sessionTotal)")
.font(.subheadline.weight(.medium))
.contentTransition(.numericText())
}
}
}
// MARK: - Mode Button
private struct ModeButton: View {
let mode: PracticeMode
let action: () -> Void
private var description: String {
switch mode {
case .flashcard: "Reveal answers at your own pace"
case .typing: "Type the conjugation from memory"
case .multipleChoice: "Pick the correct form from options"
case .fullTable: "Conjugate all persons for a verb + tense"
case .handwriting: "Write the answer with Apple Pencil"
case .sentenceBuilder: "Arrange Spanish words in correct order"
}
}
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
Image(systemName: mode.icon)
.font(.title2)
.frame(width: 40)
VStack(alignment: .leading, spacing: 2) {
Text(mode.label)
.font(.headline)
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.subheadline)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
}
}
// MARK: - Stat Item
private struct StatItem: View {
let label: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.title2.bold().monospacedDigit())
.contentTransition(.numericText())
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
extension PracticeView {
fileprivate func startIrregularityDrill(_ filter: IrregularityFilter) {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .irregularity(filter)
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(context: modelContext)
withAnimation { isPracticing = true }
}
}
#Preview {
PracticeView()
.modelContainer(for: [UserProgress.self, ReviewCard.self, Verb.self], inMemory: true)
}

View File

@@ -0,0 +1,452 @@
import SwiftUI
import SharedModels
import SwiftData
struct SentenceBuilderView: View {
@Environment(\.modelContext) private var modelContext
@State private var currentCard: VocabCard?
@State private var exampleIndex: Int = 0
@State private var shuffledWords: [IndexedWord] = []
@State private var selectedWords: [IndexedWord] = []
@State private var hasChecked: Bool = false
@State private var isCorrect: Bool = false
@State private var sessionCorrect: Int = 0
@State private var sessionTotal: Int = 0
@State private var speechService = SpeechService()
@State private var isLoading = false
@State private var hasAvailableSentences = true
private var currentSpanish: String {
guard let card = currentCard,
exampleIndex < card.examplesES.count else { return "" }
return card.examplesES[exampleIndex]
}
private var currentEnglish: String {
guard let card = currentCard,
exampleIndex < card.examplesEN.count else { return "" }
return card.examplesEN[exampleIndex]
}
private var correctWords: [String] {
currentSpanish
.components(separatedBy: " ")
.filter { !$0.isEmpty }
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
if isLoading && currentCard == nil {
ProgressView("Loading sentence...")
.frame(maxWidth: .infinity, minHeight: 220)
} else if !hasAvailableSentences {
ContentUnavailableView(
"No Sentences Available",
systemImage: "text.word.spacing",
description: Text("Complete some course lessons to unlock sentence practice.")
)
} else if currentCard != nil {
// Stats
if sessionTotal > 0 {
HStack(spacing: 16) {
Label("\(sessionCorrect)/\(sessionTotal)", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
if sessionTotal > 0 {
Text("\(Int(Double(sessionCorrect) / Double(sessionTotal) * 100))%")
.foregroundStyle(.secondary)
}
}
.font(.subheadline.weight(.medium))
}
// English prompt
VStack(spacing: 8) {
Label("Translate this sentence", systemImage: "globe")
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
Text(currentEnglish)
.font(.title3.weight(.semibold))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding()
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
Button {
speechService.speak(currentSpanish)
} label: {
Label("Listen", systemImage: "speaker.wave.2.fill")
.font(.subheadline)
}
.buttonStyle(.borderless)
.tint(.blue)
}
// Selected words area (answer)
VStack(spacing: 8) {
Text("Your answer:")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
answerArea
}
// Available word tiles
VStack(spacing: 8) {
Text("Available words:")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
wordBank
}
// Action buttons
actionButtons
}
}
.padding()
.adaptiveContainer()
}
.onAppear {
if currentCard == nil {
loadRandomSentence()
}
}
}
// MARK: - Answer Area
private var answerArea: some View {
let borderColor: Color = hasChecked ? (isCorrect ? .green : .red) : .secondary.opacity(0.3)
return FlowLayout(spacing: 8) {
ForEach(selectedWords) { word in
Button {
if !hasChecked {
withAnimation(.smooth(duration: 0.25)) {
removeWord(word)
}
}
} label: {
Text(word.text)
.font(.body.weight(.medium))
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 10))
}
.tint(.primary)
.transition(.scale.combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, minHeight: 60, alignment: .topLeading)
.padding(12)
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(borderColor, lineWidth: 2)
)
.animation(.smooth(duration: 0.3), value: hasChecked)
}
// MARK: - Word Bank
private var wordBank: some View {
FlowLayout(spacing: 8) {
ForEach(shuffledWords) { word in
let isSelected = selectedWords.contains(where: { $0.id == word.id })
Button {
if !hasChecked && !isSelected {
withAnimation(.smooth(duration: 0.25)) {
selectWord(word)
}
}
} label: {
Text(word.text)
.font(.body.weight(.medium))
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
isSelected ? Color.clear : Color.accentColor.opacity(0.12),
in: RoundedRectangle(cornerRadius: 10)
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(isSelected ? Color.secondary.opacity(0.2) : Color.accentColor.opacity(0.3), lineWidth: 1)
)
}
.tint(isSelected ? .secondary.opacity(0.4) : .primary)
.disabled(isSelected)
.transition(.scale.combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(12)
}
// MARK: - Action Buttons
private var actionButtons: some View {
VStack(spacing: 12) {
if hasChecked {
// Show result
VStack(spacing: 8) {
if isCorrect {
Label("Correct!", systemImage: "checkmark.circle.fill")
.font(.headline)
.foregroundStyle(.green)
} else {
Label("Not quite", systemImage: "xmark.circle.fill")
.font(.headline)
.foregroundStyle(.red)
VStack(spacing: 4) {
Text("Correct answer:")
.font(.caption)
.foregroundStyle(.secondary)
Text(currentSpanish)
.font(.body.weight(.semibold))
.italic()
}
.padding()
.frame(maxWidth: .infinity)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
}
.transition(.move(edge: .bottom).combined(with: .opacity))
// Navigation
HStack(spacing: 16) {
Button {
withAnimation { retrySentence() }
} label: {
Label("Retry", systemImage: "arrow.counterclockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
withAnimation { loadRandomSentence() }
} label: {
Label("Next", systemImage: "arrow.right")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
} else {
HStack(spacing: 16) {
Button {
withAnimation { clearAnswer() }
} label: {
Label("Clear", systemImage: "trash")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(selectedWords.isEmpty)
Button {
withAnimation { checkAnswer() }
} label: {
Label("Check", systemImage: "checkmark")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(selectedWords.count != correctWords.count)
}
}
}
.animation(.smooth, value: hasChecked)
}
// MARK: - Logic
private func loadRandomSentence() {
isLoading = true
hasChecked = false
isCorrect = false
selectedWords = []
defer { isLoading = false }
guard let selection = fetchRandomSentenceSelection() else {
currentCard = nil
shuffledWords = []
hasAvailableSentences = false
return
}
hasAvailableSentences = true
currentCard = selection.card
exampleIndex = selection.exampleIndex
let words = selection.spanish
.components(separatedBy: " ")
.filter { !$0.isEmpty }
shuffledWords = words.enumerated().map { IndexedWord(index: $0.offset, text: $0.element) }
shuffledWords.shuffle()
}
private func retrySentence() {
hasChecked = false
isCorrect = false
selectedWords = []
shuffledWords.shuffle()
}
private func clearAnswer() {
selectedWords = []
}
private func selectWord(_ word: IndexedWord) {
selectedWords.append(word)
}
private func removeWord(_ word: IndexedWord) {
selectedWords.removeAll { $0.id == word.id }
}
private func checkAnswer() {
let userSentence = selectedWords.map(\.text).joined(separator: " ")
isCorrect = userSentence == currentSpanish
hasChecked = true
sessionTotal += 1
if isCorrect {
sessionCorrect += 1
}
}
private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
let totalCards = CourseCardStore.fetchCardCount(context: modelContext)
guard totalCards > 0 else { return nil }
let batchSize = 32
let maxOffset = max(totalCards - batchSize, 0)
var attemptedOffsets: Set<Int> = []
for _ in 0..<8 {
let offset = maxOffset > 0 ? Int.random(in: 0...maxOffset) : 0
guard attemptedOffsets.insert(offset).inserted else { continue }
let batch = CourseCardStore.fetchCards(offset: offset, limit: batchSize, context: modelContext)
if let selection = sentenceSelection(in: batch) {
return selection
}
}
for offset in stride(from: 0, to: totalCards, by: batchSize) {
let batch = CourseCardStore.fetchCards(offset: offset, limit: batchSize, context: modelContext)
if let selection = sentenceSelection(in: batch) {
return selection
}
}
return nil
}
private func sentenceSelection(in cards: [VocabCard]) -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
for card in cards.shuffled() {
let indices = validExampleIndices(in: card)
guard let index = indices.randomElement() else { continue }
return (card, index, card.examplesES[index])
}
return nil
}
private func validExampleIndices(in card: VocabCard) -> [Int] {
guard !card.examplesES.isEmpty,
card.examplesES.count == card.examplesEN.count else { return [] }
return Array(zip(card.examplesES.indices, zip(card.examplesES, card.examplesEN)))
.compactMap { index, pair in
isValidSentencePair(spanish: pair.0, english: pair.1) ? index : nil
}
}
private func isValidSentencePair(spanish: String, english: String) -> Bool {
let junkPatterns = [
"ADVERB", "ADJECTIVE", "NOUN", "VERB", "PHRASE", "FEMININE", "MASCULINE",
"PRONOUN", "PREPOSITION", "CONJUNCTION", "INTERJECTION", "TRANSITIVE",
"INTRANSITIVE", "REFLEXIVE", "IMPERSONAL",
]
let words = spanish.components(separatedBy: " ")
let isLongEnough = words.count >= 4
let isNotJunk = !junkPatterns.contains {
english.uppercased().hasPrefix($0) || spanish.uppercased().hasPrefix($0)
}
let hasNoParens = !spanish.contains("(")
return isLongEnough && isNotJunk && hasNoParens
}
}
// MARK: - Indexed Word
private struct IndexedWord: Identifiable, Equatable {
let index: Int
let text: String
var id: Int { index }
}
// MARK: - Flow Layout
private struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
var height: CGFloat = 0
for (i, row) in rows.enumerated() {
let rowHeight = row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
height += rowHeight
if i < rows.count - 1 { height += 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
for row in rows {
let rowHeight = row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
var x = bounds.minX
for subview in row {
let size = subview.sizeThatFits(.unspecified)
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
y += rowHeight + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[LayoutSubviews.Element]] {
let maxWidth = proposal.width ?? .infinity
var rows: [[LayoutSubviews.Element]] = [[]]
var currentRowWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentRowWidth + size.width + spacing > maxWidth && !rows[rows.count - 1].isEmpty {
rows.append([])
currentRowWidth = 0
}
rows[rows.count - 1].append(subview)
currentRowWidth += size.width + spacing
}
return rows
}
}
#Preview {
NavigationStack {
SentenceBuilderView()
}
.modelContainer(for: VocabCard.self, inMemory: true)
}

View File

@@ -0,0 +1,114 @@
import SwiftUI
import SwiftData
struct TypingView: View {
@Environment(\.modelContext) private var modelContext
@Bindable var viewModel: PracticeViewModel
let speechService: SpeechService
@FocusState private var isTextFieldFocused: Bool
var body: some View {
ScrollView {
VStack(spacing: 24) {
PracticeHeaderView(
verb: viewModel.currentVerb,
tenseInfo: viewModel.currentTenseInfo,
person: viewModel.currentPerson
)
if viewModel.isAnswerRevealed {
// Result display
VStack(spacing: 16) {
if viewModel.isCorrect == true {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.green)
.symbolEffect(.bounce, value: viewModel.isCorrect)
Text("Correct!")
.font(.title2.bold())
.foregroundStyle(.green)
} else {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.red)
.symbolEffect(.bounce, value: viewModel.isCorrect)
Text("Your answer: \(viewModel.userAnswer)")
.font(.subheadline)
.foregroundStyle(.secondary)
.strikethrough()
}
}
.transition(.blurReplace)
} else {
// Input area
VStack(spacing: 16) {
TextField("Type the conjugation...", text: $viewModel.userAnswer)
.font(.title2)
.multilineTextAlignment(.center)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.default)
.focused($isTextFieldFocused)
.submitLabel(.done)
.onSubmit { submitAnswer() }
.padding()
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
Button {
submitAnswer()
} label: {
Text("Check")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.userAnswer.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.horizontal)
.transition(.blurReplace)
}
// Answer review
if viewModel.isAnswerRevealed {
AnswerReviewView(
form: viewModel.currentForm,
spans: viewModel.currentSpans,
speechService: speechService,
onRate: { quality in
viewModel.rateAnswer(quality: quality, context: modelContext)
viewModel.loadNextCard(context: modelContext)
isTextFieldFocused = true
},
onNext: {
viewModel.loadNextCard(context: modelContext)
isTextFieldFocused = true
}
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.padding()
.adaptiveContainer()
}
.animation(.smooth, value: viewModel.isAnswerRevealed)
.onAppear { isTextFieldFocused = true }
.onChange(of: viewModel.currentForm?.form) { isTextFieldFocused = true }
}
private func submitAnswer() {
guard !viewModel.userAnswer.trimmingCharacters(in: .whitespaces).isEmpty else { return }
withAnimation(.smooth) {
_ = viewModel.checkAnswer(viewModel.userAnswer)
}
}
}
#Preview {
NavigationStack {
TypingView(viewModel: PracticeViewModel(), speechService: SpeechService())
}
.modelContainer(for: Verb.self, inMemory: true)
}

View File

@@ -0,0 +1,102 @@
import SwiftUI
import SwiftData
struct SettingsView: View {
@Environment(\.modelContext) private var modelContext
@State private var progress: UserProgress?
@State private var dailyGoal: Double = 50
@State private var showVosotros: Bool = true
@State private var autoFillStem: Bool = false
@State private var selectedLevel: VerbLevel = .basic
private let levels = VerbLevel.allCases
var body: some View {
NavigationStack {
Form {
Section("Practice") {
VStack(alignment: .leading) {
Text("Daily Goal: \(Int(dailyGoal)) cards")
Slider(value: $dailyGoal, in: 10...200, step: 10)
}
.onChange(of: dailyGoal) { _, newValue in
progress?.dailyGoal = Int(newValue)
saveProgress()
}
Toggle("Include vosotros", isOn: $showVosotros)
.onChange(of: showVosotros) { _, newValue in
progress?.showVosotros = newValue
saveProgress()
}
Toggle("Auto-fill verb stem (Full Table)", isOn: $autoFillStem)
.onChange(of: autoFillStem) { _, newValue in
progress?.autoFillStem = newValue
saveProgress()
}
}
Section("Level") {
Picker("Current Level", selection: $selectedLevel) {
ForEach(levels, id: \.self) { level in
Text(level.displayName).tag(level)
}
}
.onChange(of: selectedLevel) { _, newValue in
progress?.selectedVerbLevel = newValue
saveProgress()
}
}
Section("Tenses") {
ForEach(TenseInfo.all) { tense in
Toggle(tense.english, isOn: Binding(
get: {
progress?.enabledTenseIDs.contains(tense.id) ?? false
},
set: { enabled in
guard let progress else { return }
progress.setTenseEnabled(tense.id, enabled: enabled)
saveProgress()
}
))
}
}
Section("Stats") {
if let progress {
LabeledContent("Total Reviewed", value: "\(progress.totalReviewed)")
LabeledContent("Current Streak", value: "\(progress.currentStreak) days")
LabeledContent("Longest Streak", value: "\(progress.longestStreak) days")
}
}
Section("About") {
LabeledContent("Version", value: "1.0.0")
}
}
.navigationTitle("Settings")
.onAppear(perform: loadProgress)
}
}
private func loadProgress() {
let resolved = ReviewStore.fetchOrCreateUserProgress(context: modelContext)
progress = resolved
dailyGoal = Double(resolved.dailyGoal)
showVosotros = resolved.showVosotros
autoFillStem = resolved.autoFillStem
selectedLevel = resolved.selectedVerbLevel
}
private func saveProgress() {
try? modelContext.save()
}
}
#Preview {
SettingsView()
.modelContainer(for: UserProgress.self, inMemory: true)
}

View File

@@ -0,0 +1,73 @@
import SwiftUI
import SwiftData
struct VerbDetailView: View {
@Environment(\.modelContext) private var modelContext
@State private var speechService = SpeechService()
let verb: Verb
@State private var selectedTense: TenseInfo = TenseInfo.all[0]
private var formsForTense: [VerbForm] {
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
}
var body: some View {
List {
Section {
LabeledContent("English", value: verb.english)
LabeledContent("Ending", value: "-\(verb.ending)")
LabeledContent("Level", value: VerbLevel(rawValue: verb.level)?.displayName ?? verb.level.capitalized)
if verb.reflexive > 0 {
LabeledContent("Reflexive", value: verb.reflexive == 2 ? "Always" : "Yes")
}
} header: {
Text("Info")
}
Section {
Picker("Tense", selection: $selectedTense) {
ForEach(TenseInfo.all) { tense in
Text(tense.english)
.tag(tense)
}
}
.pickerStyle(.menu)
if formsForTense.isEmpty {
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)
}
}
}
} header: {
Text("Conjugation")
}
}
.navigationTitle(verb.infinitive)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
speechService.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2.fill")
}
.tint(.secondary)
}
}
}
}
#Preview {
NavigationStack {
VerbDetailView(verb: Verb(id: 1, infinitive: "hablar", english: "to speak", rank: 1, ending: "ar", reflexive: 0, level: "basic"))
}
.modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
}

View File

@@ -0,0 +1,100 @@
import SwiftUI
import SwiftData
struct VerbListView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Verb.infinitive) private var verbs: [Verb]
@State private var searchText = ""
@State private var selectedLevel: String?
@State private var selectedVerb: Verb?
private var filteredVerbs: [Verb] {
var result = verbs
if let level = selectedLevel {
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) }
}
if !searchText.isEmpty {
let query = searchText.lowercased()
result = result.filter {
$0.infinitive.lowercased().contains(query) ||
$0.english.lowercased().contains(query)
}
}
return result
}
private let levels = ["basic", "elementary", "intermediate", "advanced", "expert"]
var body: some View {
NavigationSplitView {
List(filteredVerbs, selection: $selectedVerb) { verb in
NavigationLink(value: verb) {
VerbRowView(verb: verb)
}
}
.navigationTitle("Verbs")
.searchable(text: $searchText, prompt: "Search verbs...")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("All Levels") { selectedLevel = nil }
ForEach(levels, id: \.self) { level in
Button(level.capitalized) { selectedLevel = level }
}
} label: {
Label("Filter", systemImage: "line.3.horizontal.decrease.circle")
}
}
}
} detail: {
if let verb = selectedVerb {
VerbDetailView(verb: verb)
} else {
ContentUnavailableView("Select a Verb", systemImage: "textformat.abc", description: Text("Choose a verb from the list to see its conjugations."))
}
}
}
}
struct VerbRowView: View {
let verb: Verb
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(verb.infinitive)
.font(.headline)
Text(verb.english)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Text(verb.level.prefix(3).uppercased())
.font(.caption2)
.fontWeight(.semibold)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(levelColor(verb.level).opacity(0.15))
.foregroundStyle(levelColor(verb.level))
.clipShape(Capsule())
}
}
private func levelColor(_ level: String) -> Color {
switch level {
case "basic": return .green
case "elementary": return .blue
case "intermediate": return .orange
case "advanced": return .red
case "expert": return .purple
default: return .gray
}
}
}
#Preview {
VerbListView()
.modelContainer(for: Verb.self, inMemory: true)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,230 @@
import WidgetKit
import SwiftUI
import SwiftData
import SharedModels
import os
private let logger = Logger(subsystem: "com.conjuga.app.widget", category: "CombinedWidget")
struct CombinedEntry: TimelineEntry {
let date: Date
let word: WordOfDay?
let data: WidgetData
}
struct CombinedProvider: TimelineProvider {
private static let previewWord = WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)
func placeholder(in context: Context) -> CombinedEntry {
CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder)
}
func getSnapshot(in context: Context, completion: @escaping (CombinedEntry) -> Void) {
if context.isPreview {
completion(CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder))
return
}
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
let data = WidgetDataReader.read()
completion(CombinedEntry(date: Date(), word: word, data: data))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CombinedEntry>) -> Void) {
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
let data = WidgetDataReader.read()
let entry = CombinedEntry(date: Date(), word: word, data: data)
// Expire at midnight for new word
let tomorrow = Calendar.current.startOfDay(
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
)
completion(Timeline(entries: [entry], policy: .after(tomorrow)))
}
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
let localURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.conjuga.app")!.appendingPathComponent("local.store")
logger.info("Combined store path: \(localURL.path), exists: \(FileManager.default.fileExists(atPath: localURL.path))")
if !FileManager.default.fileExists(atPath: localURL.path) {
let dir = localURL.deletingLastPathComponent()
let contents = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
logger.error("local.store NOT FOUND. Contents: \(contents.joined(separator: ", "))")
return nil
}
guard let container = try? ModelContainer(
for: VocabCard.self, CourseDeck.self,
configurations: ModelConfiguration(
"local",
url: localURL,
cloudKitDatabase: .none
)
) else { return nil }
let context = ModelContext(container)
let wordOffset = UserDefaults(suiteName: "group.com.conjuga.app")?.integer(forKey: "wordOffset") ?? 0
guard let card = CourseCardStore.fetchWordOfDayCard(for: date, wordOffset: wordOffset, context: context) else {
return nil
}
let deckId = card.deckId
let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId }
)
let week = (try? context.fetch(deckDescriptor))?.first?.weekNumber ?? 1
return WordOfDay(spanish: card.front, english: card.back, weekNumber: week)
}
}
struct CombinedWidget: Widget {
let kind = "CombinedWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CombinedProvider()) { entry in
CombinedWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Conjuga Overview")
.description("Word of the day with daily stats and progress.")
.supportedFamilies([.systemLarge])
}
}
struct CombinedWidgetView: View {
let entry: CombinedEntry
var body: some View {
VStack(spacing: 16) {
// Word of the Day section
if let word = entry.word {
VStack(spacing: 6) {
Text("WORD OF THE DAY")
.font(.caption2.weight(.bold))
.foregroundStyle(.orange)
.tracking(1.5)
Text(word.spanish)
.font(.largeTitle.bold())
.minimumScaleFactor(0.5)
.lineLimit(1)
Text(word.english)
.font(.title3)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Text("Week \(word.weekNumber)")
.font(.caption2)
.foregroundStyle(.tertiary)
Button(intent: NewWordIntent()) {
Label("New Word", systemImage: "arrow.triangle.2.circlepath")
.font(.caption2)
}
.tint(.orange)
}
}
.frame(maxWidth: .infinity)
}
Divider()
// Stats grid
HStack(spacing: 0) {
// Daily progress
VStack(spacing: 6) {
ZStack {
Circle()
.stroke(.quaternary, lineWidth: 5)
Circle()
.trim(from: 0, to: entry.data.progressPercent)
.stroke(.orange, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 0) {
Text("\(entry.data.todayCount)")
.font(.title3.bold().monospacedDigit())
Text("/\(entry.data.dailyGoal)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(width: 60, height: 60)
Text("Today")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
// Streak
VStack(spacing: 6) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundStyle(.orange)
Text("\(entry.data.currentStreak)")
.font(.title3.bold().monospacedDigit())
Text("Streak")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
// Due cards
VStack(spacing: 6) {
Image(systemName: "clock.badge.exclamationmark")
.font(.title)
.foregroundStyle(entry.data.dueCardCount > 0 ? .blue : .secondary)
Text("\(entry.data.dueCardCount)")
.font(.title3.bold().monospacedDigit())
Text("Due")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
// Test score
VStack(spacing: 6) {
Image(systemName: entry.data.latestTestScore ?? 0 >= 90 ? "star.fill" : "pencil.and.list.clipboard")
.font(.title)
.foregroundStyle(scoreColor)
if let score = entry.data.latestTestScore {
Text("\(score)%")
.font(.title3.bold().monospacedDigit())
} else {
Text("")
.font(.title3.bold())
}
Text("Test")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 8)
}
private var scoreColor: Color {
guard let score = entry.data.latestTestScore else { return .secondary }
if score >= 90 { return .yellow }
if score >= 70 { return .green }
return .orange
}
}
#Preview(as: .systemLarge) {
CombinedWidget()
} timeline: {
CombinedEntry(
date: Date(),
word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1),
data: .placeholder
)
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.conjuga.app</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
import WidgetKit
import SwiftUI
@main
struct ConjugaWidgetBundle: WidgetBundle {
var body: some Widget {
DailyProgressWidget()
WordOfDayWidget()
WeekProgressWidget()
CombinedWidget()
}
}

View File

@@ -0,0 +1,102 @@
import WidgetKit
import SwiftUI
import SharedModels
struct DailyProgressEntry: TimelineEntry {
let date: Date
let data: WidgetData
}
struct DailyProgressProvider: TimelineProvider {
func placeholder(in context: Context) -> DailyProgressEntry {
DailyProgressEntry(date: Date(), data: .placeholder)
}
func getSnapshot(in context: Context, completion: @escaping (DailyProgressEntry) -> Void) {
if context.isPreview {
completion(DailyProgressEntry(date: Date(), data: .placeholder))
return
}
completion(DailyProgressEntry(date: Date(), data: WidgetDataReader.read()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<DailyProgressEntry>) -> Void) {
let data = WidgetDataReader.read()
var entries: [DailyProgressEntry] = []
let now = Date()
for offset in 0..<8 {
let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: now)!
entries.append(DailyProgressEntry(date: date, data: data))
}
completion(Timeline(entries: entries, policy: .atEnd))
}
}
struct DailyProgressWidget: Widget {
let kind = "DailyProgressWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: DailyProgressProvider()) { entry in
DailyProgressWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Daily Progress")
.description("Track your daily practice goal and streak.")
.supportedFamilies([.systemSmall])
}
}
struct DailyProgressWidgetView: View {
let entry: DailyProgressEntry
var body: some View {
VStack(spacing: 8) {
// Progress ring
ZStack {
Circle()
.stroke(.quaternary, lineWidth: 6)
Circle()
.trim(from: 0, to: entry.data.progressPercent)
.stroke(.orange, style: StrokeStyle(lineWidth: 6, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 0) {
Text("\(entry.data.todayCount)")
.font(.title2.bold().monospacedDigit())
Text("/\(entry.data.dailyGoal)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(width: 70, height: 70)
// Streak
if entry.data.currentStreak > 0 {
HStack(spacing: 3) {
Image(systemName: "flame.fill")
.foregroundStyle(.orange)
.font(.caption2)
Text("\(entry.data.currentStreak)d")
.font(.caption2.bold())
}
}
// Due cards
if entry.data.dueCardCount > 0 {
Text("\(entry.data.dueCardCount) due")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
#Preview(as: .systemSmall) {
DailyProgressWidget()
} timeline: {
DailyProgressEntry(date: Date(), data: .placeholder)
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Conjuga Widget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,15 @@
import AppIntents
import WidgetKit
struct NewWordIntent: AppIntent {
static let title: LocalizedStringResource = "New Word"
static let description: IntentDescription = "Show a different word of the day"
func perform() async throws -> some IntentResult {
let shared = UserDefaults(suiteName: "group.com.conjuga.app")
let current = shared?.integer(forKey: "wordOffset") ?? 0
shared?.set(current + 1, forKey: "wordOffset")
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}

View File

@@ -0,0 +1,153 @@
import WidgetKit
import SwiftUI
import SharedModels
struct WeekProgressEntry: TimelineEntry {
let date: Date
let data: WidgetData
}
struct WeekProgressProvider: TimelineProvider {
func placeholder(in context: Context) -> WeekProgressEntry {
WeekProgressEntry(date: Date(), data: .placeholder)
}
func getSnapshot(in context: Context, completion: @escaping (WeekProgressEntry) -> Void) {
if context.isPreview {
completion(WeekProgressEntry(date: Date(), data: .placeholder))
return
}
completion(WeekProgressEntry(date: Date(), data: WidgetDataReader.read()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WeekProgressEntry>) -> Void) {
let data = WidgetDataReader.read()
var entries: [WeekProgressEntry] = []
let now = Date()
for offset in 0..<8 {
let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: now)!
entries.append(WeekProgressEntry(date: date, data: data))
}
completion(Timeline(entries: entries, policy: .atEnd))
}
}
struct WeekProgressWidget: Widget {
let kind = "WeekProgressWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WeekProgressProvider()) { entry in
WeekProgressWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Week Progress")
.description("See your current week and latest test score.")
.supportedFamilies([.systemMedium])
}
}
struct WeekProgressWidgetView: View {
let entry: WeekProgressEntry
var body: some View {
HStack(spacing: 16) {
// Left: Week + Streak
VStack(spacing: 10) {
VStack(spacing: 2) {
Text("WEEK")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
Text("\(entry.data.currentWeek)")
.font(.system(size: 40, weight: .bold, design: .rounded))
}
if entry.data.currentStreak > 0 {
HStack(spacing: 3) {
Image(systemName: "flame.fill")
.foregroundStyle(.orange)
Text("\(entry.data.currentStreak)")
.fontWeight(.bold)
}
.font(.caption)
}
}
.frame(maxWidth: .infinity)
Divider()
// Right: Stats
VStack(alignment: .leading, spacing: 10) {
// Latest test score
if let score = entry.data.latestTestScore,
let week = entry.data.latestTestWeek {
HStack(spacing: 8) {
Image(systemName: scoreIcon(score))
.foregroundStyle(scoreColor(score))
.font(.title3)
VStack(alignment: .leading, spacing: 1) {
Text("Week \(week) Test")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(score)%")
.font(.headline.bold())
.foregroundStyle(scoreColor(score))
}
}
}
// Today's progress
HStack(spacing: 8) {
Image(systemName: "checkmark.circle")
.foregroundStyle(.blue)
.font(.title3)
VStack(alignment: .leading, spacing: 1) {
Text("Today")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(entry.data.todayCount) / \(entry.data.dailyGoal)")
.font(.subheadline.weight(.medium))
}
}
// Due cards
if entry.data.dueCardCount > 0 {
HStack(spacing: 8) {
Image(systemName: "clock.badge.exclamationmark")
.foregroundStyle(.orange)
.font(.title3)
VStack(alignment: .leading, spacing: 1) {
Text("Due")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(entry.data.dueCardCount) cards")
.font(.subheadline.weight(.medium))
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 4)
}
private func scoreColor(_ score: Int) -> Color {
if score >= 90 { return .green }
if score >= 70 { return .orange }
return .red
}
private func scoreIcon(_ score: Int) -> String {
if score >= 90 { return "star.fill" }
if score >= 70 { return "hand.thumbsup.fill" }
return "arrow.clockwise"
}
}
#Preview(as: .systemMedium) {
WeekProgressWidget()
} timeline: {
WeekProgressEntry(date: Date(), data: .placeholder)
}

View File

@@ -0,0 +1,16 @@
import Foundation
import SharedModels
struct WidgetDataReader {
static let suiteName = "group.com.conjuga.app"
static let dataKey = "widgetData"
static func read() -> WidgetData {
guard let shared = UserDefaults(suiteName: suiteName),
let data = shared.data(forKey: dataKey),
let decoded = try? JSONDecoder().decode(WidgetData.self, from: data) else {
return .placeholder
}
return decoded
}
}

View File

@@ -0,0 +1,201 @@
import WidgetKit
import SwiftUI
import SwiftData
import SharedModels
import os
private let logger = Logger(subsystem: "com.conjuga.app.widget", category: "WordOfDay")
struct WordOfDayEntry: TimelineEntry {
let date: Date
let word: WordOfDay?
}
struct WordOfDayProvider: TimelineProvider {
func placeholder(in context: Context) -> WordOfDayEntry {
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1))
}
private static let previewWord = WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)
func getSnapshot(in context: Context, completion: @escaping (WordOfDayEntry) -> Void) {
if context.isPreview {
completion(WordOfDayEntry(date: Date(), word: Self.previewWord))
return
}
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
completion(WordOfDayEntry(date: Date(), word: word))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WordOfDayEntry>) -> Void) {
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
let entry = WordOfDayEntry(date: Date(), word: word)
let tomorrow = Calendar.current.startOfDay(
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
)
completion(Timeline(entries: [entry], policy: .after(tomorrow)))
}
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
let localURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.conjuga.app")!.appendingPathComponent("local.store")
logger.info("Store path: \(localURL.path)")
logger.info("Store exists: \(FileManager.default.fileExists(atPath: localURL.path))")
if !FileManager.default.fileExists(atPath: localURL.path) {
let dir = localURL.deletingLastPathComponent()
let contents = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
logger.error("local.store NOT FOUND. App Support contents: \(contents.joined(separator: ", "))")
return nil
}
do {
let container = try ModelContainer(
for: VocabCard.self, CourseDeck.self,
configurations: ModelConfiguration(
"local",
url: localURL,
cloudKitDatabase: .none
)
)
logger.info("ModelContainer opened OK")
let context = ModelContext(container)
let wordOffset = UserDefaults(suiteName: "group.com.conjuga.app")?.integer(forKey: "wordOffset") ?? 0
guard let card = CourseCardStore.fetchWordOfDayCard(for: date, wordOffset: wordOffset, context: context) else {
logger.error("Store has 0 VocabCards")
return nil
}
logger.info("Picked card: \(card.front) = \(card.back)")
let deckId = card.deckId
let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId }
)
let week = (try? context.fetch(deckDescriptor))?.first?.weekNumber ?? 1
return WordOfDay(spanish: card.front, english: card.back, weekNumber: week)
} catch {
logger.error("Failed: \(error.localizedDescription)")
return nil
}
}
}
struct WordOfDayWidget: Widget {
let kind = "WordOfDayWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WordOfDayProvider()) { entry in
WordOfDayWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Word of the Day")
.description("Learn a new Spanish word every day.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
struct WordOfDayWidgetView: View {
@Environment(\.widgetFamily) var family
let entry: WordOfDayEntry
var body: some View {
if let word = entry.word {
switch family {
case .systemSmall:
smallView(word: word)
case .systemMedium:
mediumView(word: word)
default:
smallView(word: word)
}
} else {
VStack {
Image(systemName: "textformat.abc")
.font(.title)
.foregroundStyle(.secondary)
Text("Open Conjuga to start")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func smallView(word: WordOfDay) -> some View {
VStack(spacing: 8) {
Text("Word of the Day")
.font(.caption2)
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(word.spanish)
.font(.title2.bold())
.minimumScaleFactor(0.6)
.lineLimit(1)
Text(word.english)
.font(.subheadline)
.foregroundStyle(.secondary)
.minimumScaleFactor(0.6)
.lineLimit(2)
.multilineTextAlignment(.center)
Text("Week \(word.weekNumber)")
.font(.caption2)
.foregroundStyle(.orange)
}
}
private func mediumView(word: WordOfDay) -> some View {
HStack(spacing: 16) {
VStack(spacing: 4) {
Text("WORD OF THE DAY")
.font(.caption2.weight(.semibold))
.foregroundStyle(.orange)
Text(word.spanish)
.font(.largeTitle.bold())
.minimumScaleFactor(0.5)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
Divider()
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("English")
.font(.caption2)
.foregroundStyle(.secondary)
Text(word.english)
.font(.headline)
.lineLimit(2)
}
HStack {
Image(systemName: "calendar")
.font(.caption2)
Text("Week \(word.weekNumber)")
.font(.caption2)
}
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 4)
}
}
#Preview(as: .systemSmall) {
WordOfDayWidget()
} timeline: {
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1))
}
#Preview(as: .systemMedium) {
WordOfDayWidget()
} timeline: {
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1))
}

19
Conjuga/Package.swift Normal file
View File

@@ -0,0 +1,19 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Conjuga",
platforms: [.iOS(.v18)],
products: [
.library(name: "Conjuga", targets: ["Conjuga"]),
],
targets: [
.target(
name: "Conjuga",
path: "Conjuga",
resources: [
.process("Resources")
]
),
]
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env swift
// Run this script to generate a pre-built SwiftData store (default.store)
// that ships with the app bundle. No first-launch seeding needed.
import Foundation
import SwiftData
// We can't easily run this as a standalone script because it needs
// the @Model types compiled. Instead, we'll build it as part of the app.
// See DataLoader.buildPreloadedStore() below.
print("Use DataLoader.buildPreloadedStore() from within the app to generate the store.")
print("Then copy the .store file to the bundle.")

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,160 @@
{
"stats": {
"verbs": 1750,
"verbForms": 209014,
"irregularSpans": 14078,
"tenseGuides": 20
},
"sampleVerb": {
"id": 1,
"infinitive": "ser",
"english": "to be",
"rank": 1,
"ending": "er",
"reflexive": 0,
"level": "basic",
"hasConjuuData": true
},
"sampleForms": [
{
"verbId": 1,
"tenseId": "ind_presente",
"personIndex": 0,
"form": "soy",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_presente",
"personIndex": 1,
"form": "eres",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_presente",
"personIndex": 2,
"form": "es",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_presente",
"personIndex": 3,
"form": "somos",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_presente",
"personIndex": 4,
"form": "sois",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_presente",
"personIndex": 5,
"form": "son",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_preterito",
"personIndex": 0,
"form": "fui",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_preterito",
"personIndex": 1,
"form": "fuiste",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_preterito",
"personIndex": 2,
"form": "fue",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_preterito",
"personIndex": 3,
"form": "fuimos",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_preterito",
"personIndex": 4,
"form": "fuisteis",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_preterito",
"personIndex": 5,
"form": "fueron",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_imperfecto",
"personIndex": 0,
"form": "era",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_imperfecto",
"personIndex": 1,
"form": "eras",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_imperfecto",
"personIndex": 2,
"form": "era",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_imperfecto",
"personIndex": 3,
"form": "éramos",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_imperfecto",
"personIndex": 4,
"form": "erais",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_imperfecto",
"personIndex": 5,
"form": "eran",
"regularity": "irregular"
},
{
"verbId": 1,
"tenseId": "ind_futuro",
"personIndex": 0,
"form": "seré",
"regularity": "ordinary"
},
{
"verbId": 1,
"tenseId": "ind_futuro",
"personIndex": 1,
"form": "serás",
"regularity": "ordinary"
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,550 @@
#!/usr/bin/env python3
"""
Merge ConjuGato + Conjuu ES data into unified JSON for Conjuga app.
Sources:
- ConjuGato: 1,750 verbs (verb.md), irregular forms, spans, irregularity bitmasks
- Conjuu ES: 621 verbs with full conjugation tables, tense guides, conjugation rules
Output: conjuga_data.json with all verbs, forms, spans, guides
"""
import csv
import json
import re
import sqlite3
import os
import plistlib
import subprocess
BASE = "/Users/treyt/Desktop/code/Spanish"
CONJUGATO_DB = "/Applications/ConjuGato.app/WrappedBundle/Verbs.sqlite"
CONJUU_VOCAB = "/Applications/Conjuu ES.app/Contents/Resources/Vocabulary.csv"
CONJUU_GUIDE = "/Applications/Conjuu ES.app/Contents/Resources/en.lproj/Guide.strings"
CONJUU_RULES = "/Applications/Conjuu ES.app/Contents/Resources/GuideTableEntries.plist"
CONJUU_LEVELS = "/Applications/Conjuu ES.app/Contents/Resources"
OUTPUT = os.path.join(BASE, "Conjuga", "Scripts", "conjuga_data.json")
# ─── Tense metadata ───
TENSES = [
{"id": "ind_presente", "spanish": "Indicativo Presente", "english": "Present", "mood": "Indicative", "order": 0},
{"id": "ind_preterito", "spanish": "Indicativo Pretérito", "english": "Preterite", "mood": "Indicative", "order": 1},
{"id": "ind_imperfecto", "spanish": "Indicativo Imperfecto", "english": "Imperfect", "mood": "Indicative", "order": 2},
{"id": "ind_futuro", "spanish": "Indicativo Futuro", "english": "Future", "mood": "Indicative", "order": 3},
{"id": "ind_perfecto", "spanish": "Indicativo Perfecto", "english": "Present Perfect", "mood": "Indicative", "order": 4},
{"id": "ind_pluscuamperfecto", "spanish": "Indicativo Pluscuamperfecto", "english": "Pluperfect", "mood": "Indicative", "order": 5},
{"id": "ind_futuro_perfecto", "spanish": "Indicativo Futuro Perfecto", "english": "Future Perfect", "mood": "Indicative", "order": 6},
{"id": "ind_preterito_anterior", "spanish": "Indicativo Pretérito Anterior", "english": "Preterite Perfect", "mood": "Indicative", "order": 7},
{"id": "cond_presente", "spanish": "Condicional Presente", "english": "Conditional", "mood": "Conditional", "order": 8},
{"id": "cond_perfecto", "spanish": "Condicional Perfecto", "english": "Conditional Perfect", "mood": "Conditional", "order": 9},
{"id": "subj_presente", "spanish": "Subjuntivo Presente", "english": "Present Subjunctive", "mood": "Subjunctive", "order": 10},
{"id": "subj_imperfecto_1", "spanish": "Subjuntivo Imperfecto I", "english": "Past Subjunctive (ra)", "mood": "Subjunctive", "order": 11},
{"id": "subj_imperfecto_2", "spanish": "Subjuntivo Imperfecto II", "english": "Past Subjunctive (se)", "mood": "Subjunctive", "order": 12},
{"id": "subj_perfecto", "spanish": "Subjuntivo Perfecto", "english": "Subjunctive Perfect", "mood": "Subjunctive", "order": 13},
{"id": "subj_pluscuamperfecto_1", "spanish": "Subjuntivo Pluscuamperfecto I", "english": "Subjunctive Pluperfect (ra)", "mood": "Subjunctive", "order": 14},
{"id": "subj_pluscuamperfecto_2", "spanish": "Subjuntivo Pluscuamperfecto II", "english": "Subjunctive Pluperfect (se)", "mood": "Subjunctive", "order": 15},
{"id": "subj_futuro", "spanish": "Subjuntivo Futuro", "english": "Subjunctive Future", "mood": "Subjunctive", "order": 16},
{"id": "subj_futuro_perfecto", "spanish": "Subjuntivo Futuro Perfecto", "english": "Subjunctive Future Perfect", "mood": "Subjunctive", "order": 17},
{"id": "imp_afirmativo", "spanish": "Imperativo Afirmativo", "english": "Imperative", "mood": "Imperative", "order": 18},
{"id": "imp_negativo", "spanish": "Imperativo Negativo", "english": "Negative Imperative", "mood": "Imperative", "order": 19},
]
TENSE_LOOKUP = {}
for t in TENSES:
TENSE_LOOKUP[t["spanish"]] = t["id"]
PERSONS = ["yo", "", "él/ella/Ud.", "nosotros", "vosotros", "ellos/ellas/Uds."]
ENDINGS = {
"ar": {
"ind_presente": ["o", "as", "a", "amos", "áis", "an"],
"ind_preterito": ["é", "aste", "ó", "amos", "asteis", "aron"],
"ind_imperfecto": ["aba", "abas", "aba", "ábamos", "abais", "aban"],
"ind_futuro": ["aré", "arás", "ará", "aremos", "aréis", "arán"],
"cond_presente": ["aría", "arías", "aría", "aríamos", "aríais", "arían"],
"subj_presente": ["e", "es", "e", "emos", "éis", "en"],
"subj_imperfecto_1": ["ara", "aras", "ara", "áramos", "arais", "aran"],
"subj_imperfecto_2": ["ase", "ases", "ase", "ásemos", "aseis", "asen"],
"subj_futuro": ["are", "ares", "are", "áremos", "areis", "aren"],
"imp_afirmativo": ["", "a", "e", "emos", "ad", "en"],
"imp_negativo": ["", "es", "e", "emos", "éis", "en"],
},
"er": {
"ind_presente": ["o", "es", "e", "emos", "éis", "en"],
"ind_preterito": ["í", "iste", "", "imos", "isteis", "ieron"],
"ind_imperfecto": ["ía", "ías", "ía", "íamos", "íais", "ían"],
"ind_futuro": ["eré", "erás", "erá", "eremos", "eréis", "erán"],
"cond_presente": ["ería", "erías", "ería", "eríamos", "eríais", "erían"],
"subj_presente": ["a", "as", "a", "amos", "áis", "an"],
"subj_imperfecto_1": ["iera", "ieras", "iera", "iéramos", "ierais", "ieran"],
"subj_imperfecto_2": ["iese", "ieses", "iese", "iésemos", "ieseis", "iesen"],
"subj_futuro": ["iere", "ieres", "iere", "iéremos", "iereis", "ieren"],
"imp_afirmativo": ["", "e", "a", "amos", "ed", "an"],
"imp_negativo": ["", "as", "a", "amos", "áis", "an"],
},
"ir": {
"ind_presente": ["o", "es", "e", "imos", "ís", "en"],
"ind_preterito": ["í", "iste", "", "imos", "isteis", "ieron"],
"ind_imperfecto": ["ía", "ías", "ía", "íamos", "íais", "ían"],
"ind_futuro": ["iré", "irás", "irá", "iremos", "iréis", "irán"],
"cond_presente": ["iría", "irías", "iría", "iríamos", "iríais", "irían"],
"subj_presente": ["a", "as", "a", "amos", "áis", "an"],
"subj_imperfecto_1": ["iera", "ieras", "iera", "iéramos", "ierais", "ieran"],
"subj_imperfecto_2": ["iese", "ieses", "iese", "iésemos", "ieseis", "iesen"],
"subj_futuro": ["iere", "ieres", "iere", "iéremos", "iereis", "ieren"],
"imp_afirmativo": ["", "e", "a", "amos", "id", "an"],
"imp_negativo": ["", "as", "a", "amos", "áis", "an"],
},
}
# Compound tenses: auxiliary haber forms
HABER = {
"ind_perfecto": ["he", "has", "ha", "hemos", "habéis", "han"],
"ind_pluscuamperfecto": ["había", "habías", "había", "habíamos", "habíais", "habían"],
"ind_futuro_perfecto": ["habré", "habrás", "habrá", "habremos", "habréis", "habrán"],
"ind_preterito_anterior": ["hube", "hubiste", "hubo", "hubimos", "hubisteis", "hubieron"],
"cond_perfecto": ["habría", "habrías", "habría", "habríamos", "habríais", "habrían"],
"subj_perfecto": ["haya", "hayas", "haya", "hayamos", "hayáis", "hayan"],
"subj_pluscuamperfecto_1": ["hubiera", "hubieras", "hubiera", "hubiéramos", "hubierais", "hubieran"],
"subj_pluscuamperfecto_2": ["hubiese", "hubieses", "hubiese", "hubiésemos", "hubieseis", "hubiesen"],
"subj_futuro_perfecto": ["hubiere", "hubieres", "hubiere", "hubiéremos", "hubiereis", "hubieren"],
}
def get_ending_type(infinitive):
inf = infinitive.lower()
if inf.endswith("arse") or inf.endswith("erse") or inf.endswith("irse"):
core = inf[:-2]
else:
core = inf
if core.endswith("ar"):
return "ar"
elif core.endswith("er"):
return "er"
elif core.endswith("ir") or core.endswith("ír"):
return "ir"
return "ar"
def get_stem(infinitive, ending_type):
inf = infinitive.lower()
if inf.endswith("se"):
inf = inf[:-2]
if ending_type == "ar" and inf.endswith("ar"):
return inf[:-2]
elif ending_type == "er" and inf.endswith("er"):
return inf[:-2]
elif ending_type == "ir" and (inf.endswith("ir") or inf.endswith("ír")):
return inf[:-2]
return inf[:-2]
def get_participle(infinitive, ending_type):
stem = get_stem(infinitive, ending_type)
if ending_type == "ar":
return stem + "ado"
else:
return stem + "ido"
def conjugate_regular(infinitive, tense_id, ending_type):
stem = get_stem(infinitive, ending_type)
if tense_id in HABER:
participle = get_participle(infinitive, ending_type)
return [f"{aux} {participle}" for aux in HABER[tense_id]]
if tense_id in ("ind_futuro", "cond_presente"):
return [infinitive.lower().rstrip("se") + e.lstrip(ending_type[0] if tense_id == "ind_futuro" else "")
for e in ENDINGS[ending_type][tense_id]]
# Actually for future/conditional, the stem is the full infinitive
base = infinitive.lower()
if base.endswith("se"):
base = base[:-2]
return [base + ENDINGS[ending_type][tense_id][i] for i in range(6)]
if tense_id in ENDINGS[ending_type]:
endings = ENDINGS[ending_type][tense_id]
return [stem + e for e in endings]
return [""] * 6
def conjugate_future_cond(infinitive, tense_id, ending_type):
base = infinitive.lower()
if base.endswith("se"):
base = base[:-2]
endings_map = {
"ind_futuro": ["é", "ás", "á", "emos", "éis", "án"],
"cond_presente": ["ía", "ías", "ía", "íamos", "íais", "ían"],
}
if tense_id in endings_map:
return [base + e for e in endings_map[tense_id]]
return None
# ─── Step 1: Load ConjuGato verbs ───
print("Loading ConjuGato data...")
conn = sqlite3.connect(CONJUGATO_DB)
cursor = conn.cursor()
# Verbs
cursor.execute("SELECT Id, Rank, Ending, Reflexive, Spanish, English FROM Verb ORDER BY Rank")
conjugato_verbs = {}
for row in cursor.fetchall():
vid, rank, ending, reflexive, spanish, english = row
ending_map = {1: "ar", 2: "er", 4: "ir"}
conjugato_verbs[vid] = {
"id": vid,
"rank": rank,
"ending": ending_map.get(ending, "ar"),
"reflexive": reflexive,
"infinitive": spanish,
"english": english,
}
# Irregular verb forms
cursor.execute("SELECT VerbFormId, Form FROM IrregularVerbForm ORDER BY VerbFormId")
irregular_forms = {}
for vfid, form in cursor.fetchall():
irregular_forms[vfid] = form
# Irregular spans
cursor.execute("SELECT Id, VerbFormId, Type, Pattern, Start, End FROM IrregularSpan ORDER BY Id")
irregular_spans = []
for sid, vfid, stype, pattern, start, end in cursor.fetchall():
irregular_spans.append({
"verbFormId": vfid,
"type": stype,
"pattern": pattern,
"start": start,
"end": end,
})
# Irregularity bitmasks
cursor.execute("SELECT * FROM Irregularity ORDER BY VerbId")
irregularity_cols = [d[0] for d in cursor.description]
irregularity_data = {}
for row in cursor.fetchall():
verb_id = row[0]
irregularity_data[verb_id] = dict(zip(irregularity_cols[1:], row[1:]))
conn.close()
print(f" {len(conjugato_verbs)} verbs, {len(irregular_forms)} irregular forms, {len(irregular_spans)} spans")
# ─── Step 2: Load Conjuu ES conjugations ───
print("Loading Conjuu ES data...")
conjuu_verbs = {}
with open(CONJUU_VOCAB, 'r') as f:
for row in csv.reader(f):
verb_name = row[0]
tense_spanish = row[2]
tense_id = TENSE_LOOKUP.get(tense_spanish)
if not tense_id:
continue
regularity = row[1]
forms = row[3:9] # yo, tú, él, nosotros, vosotros, ellos
english = row[9]
rank = int(row[13]) if row[13] else 99999
key = verb_name.lower()
if key not in conjuu_verbs:
conjuu_verbs[key] = {
"infinitive": verb_name,
"english": english,
"rank": rank,
"tenses": {},
}
conjuu_verbs[key]["tenses"][tense_id] = {
"regularity": regularity,
"forms": forms,
}
print(f" {len(conjuu_verbs)} verbs with conjugations")
# ─── Step 3: Load tense guides ───
print("Loading tense guides...")
result = subprocess.run(['plutil', '-convert', 'xml1', '-o', '-', CONJUU_GUIDE], capture_output=True)
guide_data = plistlib.loads(result.stdout)
tense_guides = {}
for key, value in guide_data.items():
m = re.match(r'LL(.+)Guide(Top|Bottom)', key)
if m:
tense_name = m.group(1)
part = m.group(2)
if tense_name not in tense_guides:
tense_guides[tense_name] = {}
tense_guides[tense_name][part] = value
guides_output = []
for t in TENSES:
guide_key = t["spanish"].replace("Indicativo ", "").replace("Condicional ", "").replace("Subjuntivo ", "").replace("Imperativo ", "")
# Try exact match first, then various key patterns
guide = None
for gk, gv in tense_guides.items():
if gk == guide_key or gk == t["spanish"] or gk.replace(" ", "") == guide_key.replace(" ", ""):
guide = gv
break
if not guide:
# Try partial match
for gk, gv in tense_guides.items():
if guide_key.lower() in gk.lower() or gk.lower() in guide_key.lower():
guide = gv
break
guides_output.append({
"tenseId": t["id"],
"title": guide.get("Top", t["english"]) if guide else t["english"],
"body": guide.get("Bottom", "") if guide else "",
})
print(f" {len(guides_output)} tense guides")
# ─── Step 4: Load difficulty levels ───
print("Loading difficulty levels...")
level_files = [
("basic", "Basic.csv"),
("elementary_1", "Elementary-1.csv"),
("elementary_2", "Elementary-2.csv"),
("elementary_3", "Elementary-3.csv"),
("intermediate_1", "Intermediate-1.csv"),
("intermediate_2", "Intermediate-2.csv"),
("intermediate_3", "Intermediate-3.csv"),
("intermediate_4", "Intermediate-4.csv"),
]
level_verbs = {}
for level_id, filename in level_files:
path = os.path.join(CONJUU_LEVELS, filename)
with open(path, 'r') as f:
for row in csv.reader(f):
level_verbs[row[0].lower()] = level_id
print(f" {len(level_verbs)} verbs with curated levels")
# ─── Step 5: Merge everything ───
print("Merging data...")
# Map ConjuGato VerbFormId encoding
# VerbFormId = (1000 + VerbId) * 10000 + MTPP
# M: 1=Indicative, 2=Subjunctive, 3=Imperative
# T: tense within mood
# PP: person (01-08)
CONJUGATO_TENSE_MAP = {
# (mood, tense) -> tense_id
(1, 1): "ind_presente",
(1, 2): "ind_preterito",
(1, 3): "ind_imperfecto",
(1, 6): "cond_presente",
(1, 7): "ind_futuro",
(2, 1): "subj_presente",
(2, 3): "subj_imperfecto_1",
(2, 4): "subj_imperfecto_2",
(2, 7): "subj_futuro",
(3, 0): "imp_afirmativo", # person-specific
}
def decode_verb_form_id(vfid):
"""Decode VerbFormId into (verb_id, tense_id, person_index)"""
s = str(vfid)
if len(s) != 8:
return None, None, None
verb_id = int(s[:4]) - 1000
mood = int(s[4])
tense_num = int(s[5])
person = int(s[6:8])
# Handle imperative
if mood == 3:
if person >= 800:
tense_id = "imp_negativo"
person = person - 800
else:
tense_id = "imp_afirmativo"
else:
tense_id = CONJUGATO_TENSE_MAP.get((mood, tense_num))
if person >= 1 and person <= 6:
person_idx = person - 1
elif person == 7 or person == 8:
person_idx = None # vos/voseo - skip for now
else:
person_idx = None
return verb_id, tense_id, person_idx
def assign_level(rank):
if rank <= 25:
return "basic"
elif rank <= 100:
return "elementary"
elif rank <= 300:
return "intermediate"
elif rank <= 700:
return "advanced"
else:
return "expert"
# Build unified verb list
all_verbs = []
verb_forms = []
spans_output = []
for vid, cv in sorted(conjugato_verbs.items(), key=lambda x: x[1]["rank"]):
infinitive = cv["infinitive"]
inf_lower = infinitive.lower()
ending = cv["ending"]
rank = cv["rank"]
# Check Conjuu ES for this verb
conjuu = conjuu_verbs.get(inf_lower)
# Determine level
level = level_verbs.get(inf_lower, assign_level(rank))
verb_entry = {
"id": vid,
"infinitive": infinitive,
"english": cv["english"],
"rank": rank,
"ending": ending,
"reflexive": cv["reflexive"],
"level": level,
"hasConjuuData": conjuu is not None,
}
all_verbs.append(verb_entry)
# Generate forms for each tense
for tense in TENSES:
tid = tense["id"]
if conjuu and tid in conjuu["tenses"]:
# Use Conjuu ES data (pre-computed)
td = conjuu["tenses"][tid]
forms = td["forms"]
regularity = td["regularity"]
else:
# Generate from rules or ConjuGato irregular forms
regularity = "ordinary"
# Check if we have irregular forms from ConjuGato
has_irregular = vid in irregularity_data
if tid in HABER:
# Compound tense
participle = get_participle(infinitive, ending)
# Check for irregular participle from ConjuGato
forms = [f"{aux} {participle}" for aux in HABER[tid]]
regularity = "ordinary"
elif tid in ("ind_futuro", "cond_presente"):
# Future/conditional use full infinitive as stem
base = infinitive.lower()
if base.endswith("se"):
base = base[:-2]
endings_map = {
"ind_futuro": ["é", "ás", "á", "emos", "éis", "án"],
"cond_presente": ["ía", "ías", "ía", "íamos", "íais", "ían"],
}
forms = [base + e for e in endings_map[tid]]
# Check for irregular future/conditional stems from ConjuGato
if has_irregular:
# Try to find irregular forms
for pi in range(6):
mood_tense = (1, 7) if tid == "ind_futuro" else (1, 6)
vfid = (1000 + vid) * 10000 + mood_tense[0] * 1000 + mood_tense[1] * 100 + (pi + 1)
if vfid in irregular_forms:
forms[pi] = irregular_forms[vfid]
regularity = "irregular"
else:
# Simple tense
stem = get_stem(infinitive, ending)
if tid in ENDINGS.get(ending, {}):
forms = [stem + e for e in ENDINGS[ending][tid]]
else:
forms = [""] * 6
# Override with ConjuGato irregular forms
if has_irregular:
mood_map = {
"ind_presente": (1, 1), "ind_preterito": (1, 2),
"ind_imperfecto": (1, 3),
"subj_presente": (2, 1), "subj_imperfecto_1": (2, 3),
"subj_imperfecto_2": (2, 4), "subj_futuro": (2, 7),
}
if tid in mood_map:
mt = mood_map[tid]
for pi in range(6):
vfid = (1000 + vid) * 10000 + mt[0] * 1000 + mt[1] * 100 + (pi + 1)
if vfid in irregular_forms:
forms[pi] = irregular_forms[vfid]
regularity = "irregular"
elif tid == "imp_afirmativo":
for pi in range(6):
vfid = (1000 + vid) * 10000 + 3000 + (pi + 1)
if vfid in irregular_forms:
forms[pi] = irregular_forms[vfid]
regularity = "irregular"
elif tid == "imp_negativo":
for pi in range(6):
vfid = (1000 + vid) * 10000 + 3800 + (pi + 1)
if vfid in irregular_forms:
forms[pi] = irregular_forms[vfid]
regularity = "irregular"
for pi, form in enumerate(forms):
if form:
verb_forms.append({
"verbId": vid,
"tenseId": tid,
"personIndex": pi,
"form": form,
"regularity": regularity,
})
# Build spans referencing verb forms
print("Processing irregular spans...")
for span in irregular_spans:
vfid = span["verbFormId"]
verb_id, tense_id, person_idx = decode_verb_form_id(vfid)
if verb_id is None or tense_id is None or person_idx is None:
continue
if verb_id not in conjugato_verbs:
continue
spans_output.append({
"verbId": verb_id,
"tenseId": tense_id,
"personIndex": person_idx,
"type": span["type"],
"pattern": span["pattern"],
"start": span["start"],
"end": span["end"],
})
# ─── Step 6: Output ───
print("Writing output...")
output = {
"tenses": TENSES,
"persons": PERSONS,
"verbs": all_verbs,
"verbForms": verb_forms,
"irregularSpans": spans_output,
"tenseGuides": guides_output,
}
with open(OUTPUT, 'w', encoding='utf-8') as f:
json.dump(output, f, ensure_ascii=False, indent=None)
# Also write a pretty version for debugging
with open(OUTPUT.replace('.json', '_debug.json'), 'w', encoding='utf-8') as f:
json.dump({
"stats": {
"verbs": len(all_verbs),
"verbForms": len(verb_forms),
"irregularSpans": len(spans_output),
"tenseGuides": len(guides_output),
},
"sampleVerb": all_verbs[0] if all_verbs else None,
"sampleForms": verb_forms[:20],
}, f, ensure_ascii=False, indent=2)
file_size = os.path.getsize(OUTPUT) / (1024 * 1024)
print(f"\nDone!")
print(f" Verbs: {len(all_verbs)}")
print(f" Verb forms: {len(verb_forms)}")
print(f" Irregular spans: {len(spans_output)}")
print(f" Tense guides: {len(guides_output)}")
print(f" Output: {OUTPUT} ({file_size:.1f} MB)")

View File

@@ -0,0 +1,453 @@
#!/usr/bin/env python3
"""
Scrape 7 LanGo Spanish course packs from Brainscape, plus example sentences
from SpanishDict. Outputs all_courses_data.json with all courses, decks, cards,
and examples organized by week.
"""
import asyncio
import json
import re
import os
from playwright.async_api import async_playwright
BASE_URL = "https://www.brainscape.com"
OUTPUT = "/Users/treyt/Desktop/code/Spanish/Conjuga/Scripts/all_courses_data.json"
MAX_EXAMPLES = 3
PACK_URLS = [
"https://www.brainscape.com/packs/lango-spanish-beginner-ii-16514996",
"https://www.brainscape.com/packs/lango-spanish-beginner-iii-conversation-18477688",
"https://www.brainscape.com/packs/lango-spanish-intermediate-i-21508666",
"https://www.brainscape.com/packs/lango-spanish-intermediate-ii-21906841",
"https://www.brainscape.com/packs/lango-spanish-intermediate-iii-spanish-through-stories-20677744",
"https://www.brainscape.com/packs/lango-spanish-advanced-i-21511244",
"https://www.brainscape.com/packs/lango-spanish-advanced-ii-21649461",
]
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
# ---------------------------------------------------------------------------
# Parsing helpers (copied from scrape_brainscape.py and scrape_examples.py)
# ---------------------------------------------------------------------------
def parse_title_and_week(text):
"""Extract week number and clean title from page text."""
# Match "Week N: Title" or "Semana N: Title" or "Semana N Title"
m = re.match(r'(?:Week|Semana)\s+(\d+)[:\s]+(.+)', text, re.IGNORECASE)
if m:
return int(m.group(1)), m.group(2).strip()
return 0, text.strip()
def parse_cards(text):
"""Parse flashcard Q/A pairs from page text."""
cards = []
lines = text.split('\n')
skip = {'Q', 'A', 'Study These Flashcards', '', 'Brainscape', 'Find Flashcards',
'Make Flashcards', 'How It Works', 'Educators', 'Businesses', 'Academy',
'Log in', 'Get Started'}
i = 0
while i < len(lines):
line = lines[i].strip()
if re.match(r'^\d+$', line):
num = int(line)
parts = []
j = i + 1
while j < len(lines) and len(parts) < 6:
nextline = lines[j].strip()
if re.match(r'^\d+$', nextline) and int(nextline) == num + 1:
break
if nextline.startswith('LanGo Spanish') or nextline.startswith('Decks in class'):
break
if re.match(r'^(?:Week|Semana) \d+', nextline):
break
if nextline in skip:
j += 1
continue
parts.append(nextline)
j += 1
if len(parts) >= 2:
cards.append({
"front": parts[0],
"back": parts[1],
})
i = j
else:
i += 1
cards = [c for c in cards if not re.match(r'^(?:Week|Semana) \d+', c['front'])
and c['front'] not in ('Decks in class (39)', '# Cards')
and not c['front'].startswith('LanGo Spanish')
and not c['front'].startswith('You may prefer')]
return cards
def extract_word_for_lookup(front):
"""Extract the best lookup word from a card front."""
word = front.strip()
word = re.sub(r'^(el|la|los|las|un|una)\s+', '', word, flags=re.IGNORECASE)
word = re.sub(r'^(el/la|los/las)\s+', '', word, flags=re.IGNORECASE)
if ',' in word:
word = word.split(',')[0].strip()
if '/' in word:
word = word.split('/')[0].strip()
return word.lower().strip()
def parse_examples(text, lookup_word):
"""Parse example sentences from SpanishDict page text."""
examples = []
lines = text.split('\n')
for i, line in enumerate(lines):
l = line.strip()
if not l or len(l) < 15:
continue
inline_match = re.match(r'^(.+?[.!?])([A-Z].+)$', l)
if inline_match:
es = inline_match.group(1).strip()
en = inline_match.group(2).strip()
if lookup_word.lower() in es.lower() and len(es) > 10 and len(en) > 5:
examples.append({"es": es, "en": en})
if len(examples) >= MAX_EXAMPLES:
break
continue
if lookup_word.lower() in l.lower() and len(l) > 15 and len(l) < 300:
for j in range(i + 1, min(i + 3, len(lines))):
next_l = lines[j].strip()
if not next_l:
continue
if (next_l[0].isupper() and
not any(c in next_l for c in ['á', 'é', 'í', 'ó', 'ú', 'ñ', '¿', '¡'])):
examples.append({"es": l, "en": next_l})
if len(examples) >= MAX_EXAMPLES:
break
break
if len(examples) >= MAX_EXAMPLES:
break
return examples
# ---------------------------------------------------------------------------
# Scraping logic
# ---------------------------------------------------------------------------
async def discover_deck_urls(page, pack_url):
"""Visit a pack page and discover all deck URLs within it."""
print(f"\nDiscovering decks in {pack_url}...")
await page.goto(pack_url, wait_until="networkidle", timeout=30000)
await page.wait_for_timeout(2000)
# Scroll to load all content
for _ in range(10):
await page.evaluate("window.scrollBy(0, 1000)")
await page.wait_for_timeout(300)
# Extract pack ID from URL
pack_id = pack_url.rstrip('/').split('-')[-1]
# Find all deck links matching /flashcards/*/packs/*
links = await page.eval_on_selector_all(
'a[href*="/flashcards/"]',
'els => els.map(e => e.getAttribute("href"))'
)
deck_urls = []
seen = set()
for href in links:
if href and '/flashcards/' in href and '/packs/' in href:
# Normalize
if href.startswith('http'):
href = href.replace(BASE_URL, '')
if href not in seen:
seen.add(href)
deck_urls.append(href)
# Extract course name from the page
text = await page.inner_text("body")
course_name = None
# Try to find "LanGo Spanish | ..." pattern
m = re.search(r'(LanGo Spanish\s*\|\s*[^>\n]+)', text)
if m:
course_name = m.group(1).strip()
# Clean trailing noise
course_name = re.sub(r'\s*>\s*$', '', course_name).strip()
# Remove "Flashcards" suffix if present
course_name = re.sub(r'\s*Flashcards\s*$', '', course_name).strip()
else:
# Fallback: derive from URL slug
slug = pack_url.rstrip('/').split('/')[-1]
slug = re.sub(r'-\d+$', '', slug)
course_name = slug.replace('-', ' ').title()
print(f" Course: {course_name}")
print(f" Found {len(deck_urls)} deck URLs")
return course_name, deck_urls
async def scrape_deck(page, url):
"""Scrape a single deck page for flashcard data."""
full_url = BASE_URL + url if url.startswith('/') else url
await page.goto(full_url, wait_until="networkidle", timeout=30000)
await page.wait_for_timeout(2000)
for _ in range(5):
await page.evaluate("window.scrollBy(0, 1000)")
await page.wait_for_timeout(300)
text = await page.inner_text("body")
# Extract title — handle both "Week N:" and "Semana N" patterns
title_match = re.search(r'>\s*((?:Week|Semana)\s+\d+[:\s].+?)\s*>\s*Flashcards', text)
if title_match:
raw_title = title_match.group(1).strip()
else:
heading_match = re.search(r'((?:Week|Semana)\s+\d+[:\s].+?)\s*Flashcards', text)
if heading_match:
raw_title = heading_match.group(1).strip()
else:
slug = url.split('/')[2] if len(url.split('/')) > 2 else url
slug_clean = re.sub(r'-\d+$', '', slug)
slug_clean = re.sub(r'-al-rev(e|é)s$', ' AL REVÉS', slug_clean)
raw_title = slug_clean.replace('-', ' ').title()
wm = re.match(r'Week\s+(\d+)', raw_title, re.IGNORECASE)
if not wm:
raw_title = "Week 0: " + raw_title
week, title = parse_title_and_week(raw_title)
cards = parse_cards(text)
is_reversed = "al rev" in url.lower() or "AL REVÉS" in raw_title.upper()
return {
"week": week,
"title": title,
"isReversed": is_reversed,
"cardCount": len(cards),
"cards": cards,
"url": url,
}
async def scrape_examples_for_word(page, lookup):
"""Scrape example sentences from SpanishDict for a single word."""
url = f"https://www.spanishdict.com/translate/{lookup}"
try:
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
await page.wait_for_timeout(2000)
text = await page.inner_text("body")
return parse_examples(text, lookup)
except Exception:
return []
def save_progress(data):
"""Save current data to output file."""
with open(OUTPUT, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_progress():
"""Load existing progress if available."""
if os.path.exists(OUTPUT):
try:
with open(OUTPUT) as f:
return json.load(f)
except (json.JSONDecodeError, KeyError):
pass
return None
async def main():
# Check for existing progress
existing = load_progress()
completed_courses = set()
examples_done = {} # lookup -> examples list
if existing and 'courses' in existing:
for course in existing['courses']:
if course.get('_examples_done'):
completed_courses.add(course['course'])
# Collect already-scraped examples
for week in course.get('weeks', []):
for deck in week.get('decks', []):
for card in deck.get('cards', []):
if card.get('examples'):
lookup = extract_word_for_lookup(card['front'])
examples_done[lookup] = card['examples']
print(f"Loaded progress: {len(completed_courses)} completed courses, {len(examples_done)} words with examples")
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(user_agent=USER_AGENT)
page = await context.new_page()
all_courses = []
# If we have existing data for completed courses, keep them
if existing and 'courses' in existing:
for course in existing['courses']:
if course['course'] in completed_courses:
all_courses.append(course)
# ---------------------------------------------------------------
# Phase 1: Discover decks and scrape cards for each course pack
# ---------------------------------------------------------------
for pack_url in PACK_URLS:
course_name, deck_urls = await discover_deck_urls(page, pack_url)
# Skip if already completed
if course_name in completed_courses:
print(f" Skipping {course_name} (already completed)")
continue
await page.wait_for_timeout(300)
all_decks = []
total_cards = 0
for i, deck_url in enumerate(deck_urls):
slug = deck_url.split('/')[2] if len(deck_url.split('/')) > 2 else deck_url
print(f" [{i+1}/{len(deck_urls)}] Scraping {slug[:60]}...")
try:
deck = await scrape_deck(page, deck_url)
all_decks.append(deck)
total_cards += deck["cardCount"]
print(f" -> Week {deck['week']}: {deck['title']} ({deck['cardCount']} cards)")
except Exception as e:
print(f" ERROR: {e}")
await page.wait_for_timeout(300)
# Organize by week
weeks = {}
for deck in all_decks:
w = deck["week"]
if w not in weeks:
weeks[w] = []
weeks[w].append({
"title": deck["title"],
"isReversed": deck["isReversed"],
"cardCount": deck["cardCount"],
"cards": deck["cards"],
})
course_data = {
"course": course_name,
"totalDecks": len(all_decks),
"totalCards": total_cards,
"_examples_done": False,
"weeks": [
{"week": w, "decks": weeks[w]}
for w in sorted(weeks.keys())
],
}
all_courses.append(course_data)
# Save after each course
save_progress({"courses": all_courses})
print(f" Saved {course_name}: {len(all_decks)} decks, {total_cards} cards")
# ---------------------------------------------------------------
# Phase 2: Scrape example sentences from SpanishDict
# ---------------------------------------------------------------
print("\n" + "=" * 60)
print("Phase 2: Scraping example sentences from SpanishDict")
print("=" * 60)
# Collect all unique words across all courses (non-reversed decks)
unique_words = {} # lookup -> original front
for course in all_courses:
for week in course['weeks']:
for deck in week['decks']:
if deck.get('isReversed'):
continue
for card in deck['cards']:
front = card['front']
lookup = extract_word_for_lookup(front)
if lookup and lookup not in unique_words:
unique_words[lookup] = front
print(f"Found {len(unique_words)} unique words to look up")
print(f"Already have examples for {len(examples_done)} words")
words_scraped = 0
total_words = len(unique_words)
for i, (lookup, original) in enumerate(unique_words.items()):
if lookup in examples_done:
continue
print(f"[{i+1}/{total_words}] {lookup}...", end=" ", flush=True)
try:
examples = await scrape_examples_for_word(page, lookup)
examples_done[lookup] = examples
if examples:
print(f"{len(examples)} examples")
else:
print("no examples")
except Exception as e:
print(f"error: {e}")
examples_done[lookup] = []
words_scraped += 1
# Save progress every 20 words
if words_scraped % 20 == 0:
# Attach examples to cards before saving
_attach_examples(all_courses, examples_done)
save_progress({"courses": all_courses})
print(f" [saved progress - {len(examples_done)} words done]")
await page.wait_for_timeout(300)
await browser.close()
# ---------------------------------------------------------------
# Final: attach all examples to cards and save
# ---------------------------------------------------------------
_attach_examples(all_courses, examples_done)
# Mark all courses as examples_done and remove internal flag
for course in all_courses:
course['_examples_done'] = True
# Clean up internal flags before final save
for course in all_courses:
course.pop('_examples_done', None)
save_progress({"courses": all_courses})
total_decks = sum(c['totalDecks'] for c in all_courses)
total_cards = sum(c['totalCards'] for c in all_courses)
print(f"\nDone! {len(all_courses)} courses, {total_decks} decks, {total_cards} cards")
print(f"Examples scraped for {len(examples_done)} unique words")
print(f"Output: {OUTPUT}")
def _attach_examples(courses, examples_done):
"""Attach scraped examples to card objects in place."""
for course in courses:
for week in course['weeks']:
for deck in week['decks']:
for card in deck['cards']:
lookup = extract_word_for_lookup(card['front'])
if lookup in examples_done and examples_done[lookup]:
card['examples'] = examples_done[lookup]
elif 'examples' not in card:
card['examples'] = []
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
Scrape all 39 LanGo Spanish Beginner I decks from Brainscape using Playwright.
Outputs course_data.json with all decks and cards organized by week.
"""
import asyncio
import json
import re
from playwright.async_api import async_playwright
BASE_URL = "https://www.brainscape.com"
PACK_ID = "18164266"
OUTPUT = "/Users/treyt/Desktop/code/Spanish/Conjuga/Scripts/course_data.json"
DECK_URLS = [
"/flashcards/week-1-greetings-los-saludos-10176532/packs/18164266",
"/flashcards/week-1-greetings-los-saludos-al-reves-12745728/packs/18164266",
"/flashcards/week-2-adjectives-los-adjetivos-12745741/packs/18164266",
"/flashcards/week-2-adjectives-los-adjetivos-al-reves-12745829/packs/18164266",
"/flashcards/week-2-numbers-los-numeros-12797877/packs/18164266",
"/flashcards/week-2-numbers-los-numeros-al-reves-13698219/packs/18164266",
"/flashcards/week-2-professions-las-profesiones-12740531/packs/18164266",
"/flashcards/week-2-professions-las-profesiones-al-re-12745832/packs/18164266",
"/flashcards/week-3-house-la-casa-10216249/packs/18164266",
"/flashcards/week-3-house-la-casa-al-reves-12745837/packs/18164266",
"/flashcards/week-3-ar-verbs-10207117/packs/18164266",
"/flashcards/week-3-ar-verbs-al-reves-12745833/packs/18164266",
"/flashcards/week-3-er-verbs-12745857/packs/18164266",
"/flashcards/week-3-er-verbs-al-reves-12745888/packs/18164266",
"/flashcards/week-3-ir-verbs-10207120/packs/18164266",
"/flashcards/week-3-ir-verbs-al-reves-12745835/packs/18164266",
"/flashcards/week-4-family-la-familia-10266419/packs/18164266",
"/flashcards/week-4-family-la-familia-al-reves-12745978/packs/18164266",
"/flashcards/week-4-e-ie-stem-changing-verbs-10270069/packs/18164266",
"/flashcards/week-4-e-ie-stem-changing-verbs-al-reves-12749152/packs/18164266",
"/flashcards/week-4-e-i-stem-changing-verbs-10270070/packs/18164266",
"/flashcards/week-4-e-i-stem-changing-verbs-al-reves-12749160/packs/18164266",
"/flashcards/week-4-o-ue-stem-changing-verbs-10270071/packs/18164266",
"/flashcards/week-4-o-ue-stem-changing-verbs-al-reves-12749172/packs/18164266",
"/flashcards/week-4-exceptional-yo-forms-10286213/packs/18164266",
"/flashcards/week-4-exceptional-yo-forms-al-reves-12749234/packs/18164266",
"/flashcards/week-5-reflexive-verbs-los-verbos-reflex-10270072/packs/18164266",
"/flashcards/week-5-reflexive-verbs-los-verbos-reflex-12745842/packs/18164266",
"/flashcards/week-5-daily-routine-la-rutina-cotidiana-11869082/packs/18164266",
"/flashcards/week-5-daily-routine-la-rutina-cotidiana-12745840/packs/18164266",
"/flashcards/week-6-city-la-ciudad-10232784/packs/18164266",
"/flashcards/week-6-city-la-ciudad-al-reves-12745942/packs/18164266",
"/flashcards/week-6-time-expressions-las-expresiones-12797878/packs/18164266",
"/flashcards/week-6-time-expressions-las-expresiones-13698220/packs/18164266",
"/flashcards/week-7-idioms-with-the-verb-tener-los-mo-11951594/packs/18164266",
"/flashcards/week-8-prepositions-and-negation-las-pre-11951441/packs/18164266",
"/flashcards/week-8-prepositions-and-negation-las-pre-16094943/packs/18164266",
"/flashcards/week-8-hobbies-los-pasatiempos-10232782/packs/18164266",
"/flashcards/week-8-hobbies-los-pasatiempos-al-reves-12745838/packs/18164266",
]
def parse_title_and_week(text):
"""Extract week number and clean title from page text."""
# Match "Week N: Title" pattern
m = re.match(r'Week\s+(\d+):\s*(.+)', text, re.IGNORECASE)
if m:
return int(m.group(1)), m.group(2).strip()
return 0, text.strip()
def parse_cards(text):
"""Parse flashcard Q/A pairs from page text."""
cards = []
lines = text.split('\n')
# Filter out noise lines
skip = {'Q', 'A', 'Study These Flashcards', '', 'Brainscape', 'Find Flashcards',
'Make Flashcards', 'How It Works', 'Educators', 'Businesses', 'Academy',
'Log in', 'Get Started'}
i = 0
while i < len(lines):
line = lines[i].strip()
# Look for a card number
if re.match(r'^\d+$', line):
num = int(line)
# Collect content lines until the next card number or deck list
parts = []
j = i + 1
while j < len(lines) and len(parts) < 6:
nextline = lines[j].strip()
# Stop at next card number
if re.match(r'^\d+$', nextline) and int(nextline) == num + 1:
break
# Stop at deck list / footer
if nextline.startswith('LanGo Spanish') or nextline.startswith('Decks in class'):
break
# Stop at other deck titles leaking in
if re.match(r'^Week \d+:', nextline):
break
# Skip noise
if nextline in skip:
j += 1
continue
parts.append(nextline)
j += 1
if len(parts) >= 2:
cards.append({
"front": parts[0],
"back": parts[1],
})
i = j
else:
i += 1
# Post-filter: remove any cards that are actually deck titles
cards = [c for c in cards if not re.match(r'^Week \d+:', c['front'])
and c['front'] not in ('Decks in class (39)', '# Cards')
and not c['front'].startswith('LanGo Spanish')
and not c['front'].startswith('You may prefer')]
return cards
async def scrape_deck(page, url):
"""Scrape a single deck page."""
full_url = BASE_URL + url
await page.goto(full_url, wait_until="networkidle", timeout=30000)
await page.wait_for_timeout(2000)
# Scroll to load lazy content
for _ in range(5):
await page.evaluate("window.scrollBy(0, 1000)")
await page.wait_for_timeout(300)
text = await page.inner_text("body")
# Extract title — try multiple patterns
# Format: "LanGo Spanish | Beginner I > Week N: Title > Flashcards"
title_match = re.search(r'>\s*(Week\s+\d+:.+?)\s*>\s*Flashcards', text)
if title_match:
raw_title = title_match.group(1).strip()
else:
# Try: "Week N: Title (Subtitle) Flashcards"
heading_match = re.search(r'(Week\s+\d+:.+?)\s*Flashcards', text)
if heading_match:
raw_title = heading_match.group(1).strip()
else:
# Last resort: extract from URL slug
slug = url.split('/')[2]
# Convert "week-5-reflexive-verbs-los-verbos-reflex-10270072" to title
slug_clean = re.sub(r'-\d+$', '', slug) # remove trailing ID
slug_clean = re.sub(r'-al-rev(e|é)s$', ' AL REVÉS', slug_clean)
raw_title = slug_clean.replace('-', ' ').title()
# Try to extract week number
wm = re.match(r'Week\s+(\d+)', raw_title, re.IGNORECASE)
if wm:
raw_title = raw_title # already has Week N
else:
raw_title = "Week 0: " + raw_title
week, title = parse_title_and_week(raw_title)
cards = parse_cards(text)
is_reversed = "al rev" in url.lower() or "AL REVÉS" in raw_title.upper()
return {
"week": week,
"title": title,
"isReversed": is_reversed,
"cardCount": len(cards),
"cards": cards,
"url": url,
}
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
page = await context.new_page()
all_decks = []
total_cards = 0
for i, url in enumerate(DECK_URLS):
print(f"[{i+1}/{len(DECK_URLS)}] Scraping {url.split('/')[2][:50]}...")
try:
deck = await scrape_deck(page, url)
all_decks.append(deck)
total_cards += deck["cardCount"]
print(f" → Week {deck['week']}: {deck['title']} ({deck['cardCount']} cards)")
except Exception as e:
print(f" ERROR: {e}")
# Be polite
await page.wait_for_timeout(500)
await browser.close()
# Organize by week
weeks = {}
for deck in all_decks:
w = deck["week"]
if w not in weeks:
weeks[w] = []
weeks[w].append({
"title": deck["title"],
"isReversed": deck["isReversed"],
"cardCount": deck["cardCount"],
"cards": deck["cards"],
})
output = {
"course": "LanGo Spanish | Beginner I",
"totalDecks": len(all_decks),
"totalCards": total_cards,
"weeks": [
{
"week": w,
"decks": weeks[w],
}
for w in sorted(weeks.keys())
],
}
with open(OUTPUT, 'w', encoding='utf-8') as f:
json.dump(output, f, ensure_ascii=False, indent=2)
print(f"\nDone! {len(all_decks)} decks, {total_cards} cards → {OUTPUT}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Scrape 2-3 example sentences per vocab word from SpanishDict.
Reads words from course_data.json, outputs examples to course_examples.json.
"""
import asyncio
import json
import re
import os
from playwright.async_api import async_playwright
INPUT = "/Users/treyt/Desktop/code/Spanish/Conjuga/Scripts/course_data.json"
OUTPUT = "/Users/treyt/Desktop/code/Spanish/Conjuga/Scripts/course_examples.json"
MAX_EXAMPLES = 3
def extract_word_for_lookup(front):
"""Extract the best lookup word from a card front.
e.g. 'barato, barata' -> 'barato'
e.g. 'el/la periodista' -> 'periodista'
"""
word = front.strip()
# Remove articles
word = re.sub(r'^(el|la|los|las|un|una)\s+', '', word, flags=re.IGNORECASE)
word = re.sub(r'^(el/la|los/las)\s+', '', word, flags=re.IGNORECASE)
# Take first word if comma-separated (barato, barata -> barato)
if ',' in word:
word = word.split(',')[0].strip()
# Take first word if slash-separated
if '/' in word:
word = word.split('/')[0].strip()
return word.lower().strip()
def parse_examples(text, lookup_word):
"""Parse example sentences from SpanishDict page text."""
examples = []
lines = text.split('\n')
for i, line in enumerate(lines):
l = line.strip()
if not l or len(l) < 15:
continue
# Pattern: "Spanish sentence.English sentence." (inline on one line)
# SpanishDict puts them together with no space between period and capital
# e.g. "Esta tienda es muy barata.This store is really cheap."
inline_match = re.match(r'^(.+?[.!?])([A-Z].+)$', l)
if inline_match:
es = inline_match.group(1).strip()
en = inline_match.group(2).strip()
# Verify it contains our word (case-insensitive)
if lookup_word.lower() in es.lower() and len(es) > 10 and len(en) > 5:
examples.append({"es": es, "en": en})
if len(examples) >= MAX_EXAMPLES:
break
continue
# Pattern: standalone Spanish sentence with word, followed by English on next line
if lookup_word.lower() in l.lower() and len(l) > 15 and len(l) < 300:
# Check if next non-empty line is English
for j in range(i + 1, min(i + 3, len(lines))):
next_l = lines[j].strip()
if not next_l:
continue
# Check if it looks English (starts with capital, has common English words)
if (next_l[0].isupper() and
not any(c in next_l for c in ['á', 'é', 'í', 'ó', 'ú', 'ñ', '¿', '¡'])):
examples.append({"es": l, "en": next_l})
if len(examples) >= MAX_EXAMPLES:
break
break
if len(examples) >= MAX_EXAMPLES:
break
return examples
async def scrape_word(page, word, lookup):
"""Scrape examples for a single word."""
url = f"https://www.spanishdict.com/translate/{lookup}"
try:
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
await page.wait_for_timeout(2000)
text = await page.inner_text("body")
examples = parse_examples(text, lookup)
return examples
except Exception as e:
return []
async def main():
# Load course data
with open(INPUT) as f:
data = json.load(f)
# Collect unique words (front values from non-reversed decks)
words = {} # lookup -> original front
for week in data['weeks']:
for deck in week['decks']:
if deck.get('isReversed'):
continue
for card in deck['cards']:
front = card['front']
lookup = extract_word_for_lookup(front)
if lookup and lookup not in words:
words[lookup] = front
print(f"Found {len(words)} unique words to look up")
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
ctx = await browser.new_context(
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
page = await ctx.new_page()
# Load existing progress if any
results = {}
if os.path.exists(OUTPUT):
with open(OUTPUT) as f:
results = json.load(f)
print(f"Loaded {len(results)} existing results")
found = len(results)
total = len(words)
for i, (lookup, original) in enumerate(words.items()):
# Skip already scraped
if original in results:
continue
print(f"[{i+1}/{total}] {lookup}...", end=" ", flush=True)
try:
examples = await scrape_word(page, original, lookup)
if examples:
results[original] = examples
found += 1
print(f"{len(examples)} examples")
else:
results[original] = []
print("no examples")
except Exception as e:
print(f"error: {e}")
results[original] = []
# Save progress every 20 words
if (i + 1) % 20 == 0:
with open(OUTPUT, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f" [saved {len(results)} results]")
await page.wait_for_timeout(300)
await browser.close()
# Save results
with open(OUTPUT, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\nDone! {found}/{total} words with examples → {OUTPUT}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,13 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "SharedModels",
platforms: [.iOS(.v18)],
products: [
.library(name: "SharedModels", targets: ["SharedModels"]),
],
targets: [
.target(name: "SharedModels"),
]
)

View File

@@ -0,0 +1,75 @@
import CryptoKit
import Foundation
import SwiftData
public enum CourseCardStore {
private static var sortDescriptors: [SortDescriptor<VocabCard>] {
[
SortDescriptor(\VocabCard.deckId),
SortDescriptor(\VocabCard.front),
SortDescriptor(\VocabCard.back),
]
}
public static func reviewKey(for card: VocabCard) -> String {
reviewKey(
deckId: card.deckId,
front: card.front,
back: card.back,
examplesES: card.examplesES,
examplesEN: card.examplesEN
)
}
public static func reviewKey(
deckId: String,
front: String,
back: String,
examplesES: [String],
examplesEN: [String]
) -> String {
let source = [
deckId,
front,
back,
examplesES.joined(separator: "\u{1F}"),
examplesEN.joined(separator: "\u{1F}"),
].joined(separator: "\u{1E}")
let digest = SHA256.hash(data: Data(source.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
public static func fetchCardCount(context: ModelContext) -> Int {
(try? context.fetchCount(FetchDescriptor<VocabCard>())) ?? 0
}
public static func fetchCard(at index: Int, context: ModelContext) -> VocabCard? {
guard index >= 0 else { return nil }
var descriptor = FetchDescriptor<VocabCard>(sortBy: sortDescriptors)
descriptor.fetchOffset = index
descriptor.fetchLimit = 1
return (try? context.fetch(descriptor))?.first
}
public static func fetchCards(offset: Int, limit: Int, context: ModelContext) -> [VocabCard] {
guard limit > 0 else { return [] }
var descriptor = FetchDescriptor<VocabCard>(sortBy: sortDescriptors)
descriptor.fetchOffset = max(offset, 0)
descriptor.fetchLimit = limit
return (try? context.fetch(descriptor)) ?? []
}
public static func fetchWordOfDayCard(
for date: Date,
wordOffset: Int,
context: ModelContext
) -> VocabCard? {
let count = fetchCardCount(context: context)
guard count > 0 else { return nil }
let dayOfYear = Calendar.current.ordinality(of: .day, in: .year, for: date) ?? 1
let index = (dayOfYear + wordOffset) % count
return fetchCard(at: index, context: context)
}
}

View File

@@ -0,0 +1,24 @@
import SwiftData
import Foundation
@Model
public final class CourseDeck {
public var id: String = ""
public var weekNumber: Int = 0
public var title: String = ""
public var cardCount: Int = 0
public var courseName: String = ""
public var isReversed: Bool = false
@Relationship(deleteRule: .cascade, inverse: \VocabCard.deck)
public var cards: [VocabCard]?
public init(id: String, weekNumber: Int, title: String, cardCount: Int, courseName: String, isReversed: Bool) {
self.id = id
self.weekNumber = weekNumber
self.title = title
self.cardCount = cardCount
self.courseName = courseName
self.isReversed = isReversed
}
}

View File

@@ -0,0 +1,28 @@
import SwiftData
import Foundation
@Model
public final class VocabCard {
public var front: String = ""
public var back: String = ""
public var deckId: String = ""
public var examplesES: [String] = []
public var examplesEN: [String] = []
public var deck: CourseDeck?
// Legacy SRS fields retained only so old local progress can be migrated to CourseReviewCard.
public var easeFactor: Double = 2.5
public var interval: Int = 0
public var repetitions: Int = 0
public var dueDate: Date = Date()
public var lastReviewDate: Date?
public init(front: String, back: String, deckId: String, examplesES: [String] = [], examplesEN: [String] = []) {
self.front = front
self.back = back
self.deckId = deckId
self.examplesES = examplesES
self.examplesEN = examplesEN
}
}

View File

@@ -0,0 +1,64 @@
import Foundation
public struct WordOfDay: Codable, Equatable, Sendable {
public var spanish: String
public var english: String
public var weekNumber: Int
public init(spanish: String, english: String, weekNumber: Int) {
self.spanish = spanish
self.english = english
self.weekNumber = weekNumber
}
}
public struct WidgetData: Codable, Equatable, Sendable {
public var todayCount: Int
public var dailyGoal: Int
public var currentStreak: Int
public var dueCardCount: Int
public var wordOfTheDay: WordOfDay?
public var latestTestScore: Int?
public var latestTestWeek: Int?
public var currentWeek: Int
public var lastUpdated: Date
public init(
todayCount: Int,
dailyGoal: Int,
currentStreak: Int,
dueCardCount: Int,
wordOfTheDay: WordOfDay?,
latestTestScore: Int?,
latestTestWeek: Int?,
currentWeek: Int,
lastUpdated: Date
) {
self.todayCount = todayCount
self.dailyGoal = dailyGoal
self.currentStreak = currentStreak
self.dueCardCount = dueCardCount
self.wordOfTheDay = wordOfTheDay
self.latestTestScore = latestTestScore
self.latestTestWeek = latestTestWeek
self.currentWeek = currentWeek
self.lastUpdated = lastUpdated
}
public static let placeholder = WidgetData(
todayCount: 12,
dailyGoal: 50,
currentStreak: 3,
dueCardCount: 8,
wordOfTheDay: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1),
latestTestScore: 85,
latestTestWeek: 2,
currentWeek: 2,
lastUpdated: Date()
)
public var progressPercent: Double {
guard dailyGoal > 0 else { return 0 }
return min(Double(todayCount) / Double(dailyGoal), 1.0)
}
}

84
Conjuga/project.yml Normal file
View File

@@ -0,0 +1,84 @@
name: Conjuga
options:
bundleIdPrefix: com.conjuga
deploymentTarget:
iOS: "26.0"
xcodeVersion: "26.0"
packages:
SharedModels:
path: SharedModels
settings:
base:
SWIFT_VERSION: "6.0"
CURRENT_PROJECT_VERSION: 1
MARKETING_VERSION: 1.0.0
targets:
Conjuga:
type: application
platform: iOS
sources:
- path: Conjuga
excludes:
- "*.json"
- PrebuiltStore
- path: Conjuga/conjuga_data.json
buildPhase: resources
- path: Conjuga/course_data.json
buildPhase: resources
- path: Conjuga/PrebuiltStore
type: folder
buildPhase: resources
info:
path: Conjuga/Info.plist
properties:
CFBundleDisplayName: Conjuga
LSApplicationCategoryType: public.app-category.education
UILaunchScreen: {}
UIBackgroundModes:
- remote-notification
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.conjuga.app
CODE_SIGN_ENTITLEMENTS: Conjuga/Conjuga.entitlements
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_DEBUG_DYLIB: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
TARGETED_DEVICE_FAMILY: "1,2"
DEVELOPMENT_TEAM: V3PF3M6B6U
CODE_SIGN_STYLE: Automatic
dependencies:
- target: ConjugaWidgetExtension
- package: SharedModels
ConjugaWidgetExtension:
type: app-extension
platform: iOS
sources:
- path: ConjugaWidget
info:
path: ConjugaWidget/Info.plist
properties:
CFBundleDisplayName: Conjuga Widget
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.conjuga.app.widget
CODE_SIGN_ENTITLEMENTS: ConjugaWidget/ConjugaWidget.entitlements
DEVELOPMENT_TEAM: V3PF3M6B6U
CODE_SIGN_STYLE: Automatic
SWIFT_STRICT_CONCURRENCY: complete
TARGETED_DEVICE_FAMILY: "1,2"
ENABLE_DEBUG_DYLIB: NO
dependencies:
- package: SharedModels

227
app_features.md Normal file
View File

@@ -0,0 +1,227 @@
# Spanish Conjugation Apps — Feature Comparison
Side-by-side feature analysis of **ConjuGato** and **Conjuu ES**, based on App Store screenshots and extracted app data.
---
## ConjuGato
**Platform:** iOS (iPad-style app via Mac Catalyst)
**Monetization:** One-time purchase, no subscriptions
**Tech stack:** .NET MAUI (Xamarin), SQLite, MvvmCross
### Practice Mode
- **Flashcard-style quiz** — shows English meaning, infinitive, tense tag (Presente/Subjuntivo), random pronoun
- **Tap to reveal** answer below the blank line
- **Daily goal tracker** — progress bar at top (e.g. 25/100)
- **Self-rating** — rate answers as Easy/Hard after reveal (no typing)
- **Spaced repetition** — tracks conjugation ratings with star system (15 stars) and heart indicators
- **Practice history** — shows today/yesterday verb performance with star ratings
### Verb Reference
- **1,750 verbs** ranked by frequency/popularity
- **Searchable verb list** sorted by popularity
- **Full conjugation tables** per verb with Indicative/Subjunctive/Others tabs
- **All 6 persons** — yo, tú, él/ella/Ud., nosotros, vosotros, ellos/ellas/Uds.
- **27 tenses** including progressive forms and compound tenses
### Grammar Explanations
- **Visual conjugation breakdown** — shows stem + ending decomposition (e.g. `habl- → drop ending → +o`)
- **Color-coded irregular highlighting** — each span of an irregular form colored by change type (spelling, stem, unique)
- **Irregularity classification** — each form labeled as Regular (green), Spelling change, Stem change, or Highly irregular
- **Star ratings** — ★★★ regular, ★★ stem change, ★ irregular
- **Similar forms listed** — shows other verbs that follow the same pattern (e.g. "Similar forms: satisfago, deshago")
- **Tense guides** — explains what each tense is used for with:
- Usage rules in plain English
- Mnemonics (e.g. "apply the movie rule: Imperfect = setting, Preterite = plot")
- Example sentences with Spanish + English + audio
- Tips for avoiding common confusions (e.g. Preterite vs Imperfect)
### Mnemonic Rhymes
- **Rhyming verb groups** — groups verbs that sound alike for memorization (e.g. picar, pecar, pegar, pagar, pasar, pisar, pesar, pensar)
- **Dedicated Rhymes tab** in bottom navigation
### Audio
- **Tap to pronounce** any conjugation in the verb table
- **Auto-pronounce answers** option in settings
- **Audio on tense guide examples**
- Uses iOS text-to-speech (no bundled audio files)
### Settings & Filters
- **Tense filters** — toggle individual tenses on/off: Present, Preterite, Imperfect, Conditional, Future, all Subjunctive forms
- **Show progressive forms** toggle
- **Show tense names in Spanish** toggle
- **Verb ending filters** — practice only -AR, -ER, or -IR verbs
- **Conjugation difficulty filters:**
- Fully regular
- With spelling changes
- With stem changes
- Highly irregular
- **Hide "vosotros" conjugations**
- **Auto-pronounce answers**
- **Verb count display** — shows how many verbs match current filters (e.g. "848")
### Navigation
- **5 tabs:** Practice, Rhymes, Verbs, Settings (bottom bar)
- **"Get Pro" tab** for upgrade
### Data (extracted)
| Asset | Count |
|-------|-------|
| Verbs | 1,750 |
| Irregular verb forms | 14,163 |
| Irregular span annotations | 23,795 |
| Irregularity bitmasks | 627 verbs |
| Example sentences | separate SQLite DB |
| Translations | 8 languages |
---
## Conjuu ES
**Platform:** macOS native + iOS
**Monetization:** Free tier (25 Basic verbs) + paid unlock
**Tech stack:** Native Swift/Obj-C, Core Data
### Practice Mode
- **Typing-based quiz** — fill in all 5 person forms (yo, tú, él, nosotros, ellos) for a given verb + tense
- **Full-table entry** — type all forms at once, then tap "Answer" to check
- **Progress counter** — "1/5" in top-left shows quiz position
- **Session-based practice** — complete a set of verbs per session
- **Keyboard input** — full keyboard with Spanish character support
- **Hard/Easy self-rating** after each answer
### Verb Reference
- **621 verbs** with frequency rankings
- **Searchable verb list**
- **Full conjugation tables** — all 6 persons × 20 tenses
- **4-language translations** — English, Japanese, Chinese Simplified, Chinese Traditional
- **Regularity labeling** — ordinary, irregular, regular, orto (orthographic change)
### Grammar Explanations
- **Tense guide popups** — full-screen modal with:
- Conjugation ending table showing -AR/-ER/-IR patterns side by side
- Color-coded endings (stem in white, ending in colored text)
- Numbered usage rules
- Example sentences with Spanish + English translations
- "GOT IT!" dismiss button
- **7 usage categories** for Present tense alone:
1. Current actions and situations
2. Abilities and descriptions
3. Habitual actions
4. Absolute and universal truths
5. Actions in the near future
6. Recounting historical events
7. Conditional sentences
### Tense Selection
- **20 tenses** organized by mood:
- **Indicative:** Presente, Perfecto, Imperfecto, Pluscuamperfecto, Pretérito, Pretérito Anterior, Futuro, Futuro Perfecto
- **Subjunctive:** Presente, Perfecto, Imperfecto I, Imperfecto II, Pluscuamperfecto I, Pluscuamperfecto II
- **Conditional:** Presente, Perfecto (listed under Indicative in UI)
- **Imperative:** Afirmativo, Negativo (listed under Subjunctive in UI)
- **Checkbox toggles** — enable/disable each tense for practice
- **All visible at once** in sidebar panel
### Study Lists & Difficulty Levels
- **8 built-in levels:**
- Basic (25 verbs)
- Elementary 13 (83 verbs)
- Intermediate 14 (112 verbs)
- Advanced (344 verbs)
- **"All verbs"** option (621 verbs)
- **Custom lists** — "My Lists" section with ability to create personal lists
- **Pinboard** — save specific verbs for focused review
- **Study/Review toggle** — switch between learning and review modes
### Audio
- Not visible in screenshots (no speaker icons on practice screen)
- macOS app may use system TTS
### Settings & Filters
- **Tense selection panel** — sidebar with checkboxes per tense
- **Study list selection** — dropdown for difficulty level
- **Daily goal** — choose practice intensity:
- 20 verbs / ~5 min (Casual)
- 50 verbs / ~10 min (Regular)
- 75 verbs / ~15 min (Focused)
- 100 verbs / ~20 min (Committed)
### Navigation
- **macOS sidebar** with Study/Review tabs
- **iOS bottom tabs:** Practice, Rhymes, Get Pro, Verbs, Settings
- **Study Lists** in left sidebar with collapsible groups
### Data (extracted)
| Asset | Count |
|-------|-------|
| Verbs | 621 |
| Conjugated rows | 12,406 (all forms pre-computed) |
| Tenses | 20 |
| Tense guides | 20 (with usage rules + examples) |
| Conjugation rule templates | 20 tenses × 3 verb types |
| Difficulty levels | 8 |
| Translations | 4 languages |
---
## Feature Comparison
| Feature | ConjuGato | Conjuu ES |
|---------|-----------|-----------|
| **Verb count** | 1,750 | 621 |
| **Tenses** | 27 | 20 |
| **Practice style** | Flashcard (tap to reveal) | Typing (fill in forms) |
| **Input method** | Self-rate (no typing) | Type conjugation |
| **Daily goals** | Numeric counter (25/100) | 4 presets (Casual→Committed) |
| **Spaced repetition** | Yes (star + heart ratings) | Session-based review |
| **Rhyming mnemonics** | Yes (dedicated tab) | Not visible |
| **Grammar breakdown** | Color-coded span highlighting | Ending table + usage rules |
| **Irregularity detail** | 3-level (spelling/stem/unique) with character spans | 4-label (ordinary/irregular/regular/orto) |
| **Tense explanations** | In-app guides with mnemonics | Modal popups with usage categories |
| **Example sentences** | Per-tense with audio | Per-tense in guide popups |
| **Audio** | Tap any conjugation + auto-pronounce | Not prominent |
| **Difficulty levels** | Filter by irregularity type | 8 graded word lists |
| **Custom lists** | No | Yes (My Lists + Pinboard) |
| **Verb filters** | Ending type + irregularity + tense | Tense checkboxes + level |
| **Translations** | 8 languages | 4 languages |
| **Platform** | iOS (Catalyst) | macOS + iOS |
| **Monetization** | One-time purchase | Free tier + paid |
| **vosotros toggle** | Yes | Not visible |
## Unique Strengths
### ConjuGato excels at:
- **Breadth** — nearly 3× the verbs
- **Irregularity teaching** — character-level color-coded highlighting showing exactly *why* each form is irregular
- **Rhyming mnemonics** — unique memorization technique grouping similar-sounding verbs
- **Audio integration** — every conjugation is tappable for pronunciation
- **Quick practice** — flashcard format is faster per verb than typing
- **Tense coverage** — 27 tenses including progressive forms
### Conjuu ES excels at:
- **Active recall** — typing forces recall vs passive recognition
- **Complete conjugation tables** — every form pre-computed and stored
- **Structured difficulty** — 8 graded levels from Basic to Advanced
- **Tense explanations** — richer usage guides with numbered categories
- **Conjugation rules** — extractable ending patterns for all verb types
- **Custom study lists** — personalized verb collections
- **Multi-platform** — native macOS app with full-screen practice
- **Full-table practice** — fill in all 5 persons at once, not just one random pronoun