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:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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/
|
||||
BIN
Conjuga/AppIcons/AppIcon-1024.png
Normal file
BIN
Conjuga/AppIcons/AppIcon-1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
25
Conjuga/AppIcons/AppIcon.svg
Normal file
25
Conjuga/AppIcons/AppIcon.svg
Normal 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 |
474
Conjuga/AppIcons/icon5-variations.html
Normal file
474
Conjuga/AppIcons/icon5-variations.html
Normal 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>tú</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
438
Conjuga/AppIcons/icons.html
Normal 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">tú</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>
|
||||
77
Conjuga/AppIcons/render-5a.html
Normal file
77
Conjuga/AppIcons/render-5a.html
Normal 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>
|
||||
839
Conjuga/Conjuga.xcodeproj/project.pbxproj
Normal file
839
Conjuga/Conjuga.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
Conjuga/Conjuga.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Conjuga/Conjuga.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
BIN
Conjuga/Conjuga/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
Conjuga/Conjuga/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
Conjuga/Conjuga/Assets.xcassets/Contents.json
Normal file
6
Conjuga/Conjuga/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
19
Conjuga/Conjuga/Conjuga.entitlements
Normal file
19
Conjuga/Conjuga/Conjuga.entitlements
Normal 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>
|
||||
77
Conjuga/Conjuga/ConjugaApp.swift
Normal file
77
Conjuga/Conjuga/ConjugaApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
43
Conjuga/Conjuga/Info.plist
Normal file
43
Conjuga/Conjuga/Info.plist
Normal 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>
|
||||
49
Conjuga/Conjuga/Models/DailyLog.swift
Normal file
49
Conjuga/Conjuga/Models/DailyLog.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
889
Conjuga/Conjuga/Models/GrammarNote.swift
Normal file
889
Conjuga/Conjuga/Models/GrammarNote.swift
Normal 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.
|
||||
"""
|
||||
)
|
||||
}
|
||||
40
Conjuga/Conjuga/Models/IrregularSpan.swift
Normal file
40
Conjuga/Conjuga/Models/IrregularSpan.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
86
Conjuga/Conjuga/Models/QuizType.swift
Normal file
86
Conjuga/Conjuga/Models/QuizType.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
57
Conjuga/Conjuga/Models/ReviewCard.swift
Normal file
57
Conjuga/Conjuga/Models/ReviewCard.swift
Normal 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
|
||||
}
|
||||
}
|
||||
288
Conjuga/Conjuga/Models/TenseEndingTable.swift
Normal file
288
Conjuga/Conjuga/Models/TenseEndingTable.swift
Normal 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", "tú", "é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 }
|
||||
}
|
||||
}
|
||||
15
Conjuga/Conjuga/Models/TenseGuide.swift
Normal file
15
Conjuga/Conjuga/Models/TenseGuide.swift
Normal 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
|
||||
}
|
||||
}
|
||||
78
Conjuga/Conjuga/Models/TenseInfo.swift
Normal file
78
Conjuga/Conjuga/Models/TenseInfo.swift
Normal 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", "tú", "é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 }
|
||||
}
|
||||
}
|
||||
74
Conjuga/Conjuga/Models/TestResult.swift
Normal file
74
Conjuga/Conjuga/Models/TestResult.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
88
Conjuga/Conjuga/Models/UserProgress.swift
Normal file
88
Conjuga/Conjuga/Models/UserProgress.swift
Normal 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
|
||||
}
|
||||
}
|
||||
66
Conjuga/Conjuga/Models/Verb.swift
Normal file
66
Conjuga/Conjuga/Models/Verb.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
24
Conjuga/Conjuga/Models/VerbForm.swift
Normal file
24
Conjuga/Conjuga/Models/VerbForm.swift
Normal 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
|
||||
}
|
||||
}
|
||||
141
Conjuga/Conjuga/Services/AchievementService.swift
Normal file
141
Conjuga/Conjuga/Services/AchievementService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
44
Conjuga/Conjuga/Services/CourseReviewStore.swift
Normal file
44
Conjuga/Conjuga/Services/CourseReviewStore.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
292
Conjuga/Conjuga/Services/DataLoader.swift
Normal file
292
Conjuga/Conjuga/Services/DataLoader.swift
Normal 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
|
||||
}
|
||||
}
|
||||
119
Conjuga/Conjuga/Services/HandwritingRecognizer.swift
Normal file
119
Conjuga/Conjuga/Services/HandwritingRecognizer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
232
Conjuga/Conjuga/Services/PracticeSessionService.swift
Normal file
232
Conjuga/Conjuga/Services/PracticeSessionService.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
74
Conjuga/Conjuga/Services/ReferenceStore.swift
Normal file
74
Conjuga/Conjuga/Services/ReferenceStore.swift
Normal 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)) ?? []
|
||||
}
|
||||
}
|
||||
200
Conjuga/Conjuga/Services/ReviewStore.swift
Normal file
200
Conjuga/Conjuga/Services/ReviewStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
64
Conjuga/Conjuga/Services/SRSEngine.swift
Normal file
64
Conjuga/Conjuga/Services/SRSEngine.swift
Normal 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 {}
|
||||
41
Conjuga/Conjuga/Services/SpeechService.swift
Normal file
41
Conjuga/Conjuga/Services/SpeechService.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
175
Conjuga/Conjuga/Services/StartupCoordinator.swift
Normal file
175
Conjuga/Conjuga/Services/StartupCoordinator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
86
Conjuga/Conjuga/Services/WidgetDataService.swift
Normal file
86
Conjuga/Conjuga/Services/WidgetDataService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
260
Conjuga/Conjuga/ViewModels/PracticeViewModel.swift
Normal file
260
Conjuga/Conjuga/ViewModels/PracticeViewModel.swift
Normal 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", "ió", "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"
|
||||
}
|
||||
}
|
||||
20
Conjuga/Conjuga/Views/Components/AdaptiveContainer.swift
Normal file
20
Conjuga/Conjuga/Views/Components/AdaptiveContainer.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
45
Conjuga/Conjuga/Views/Components/DailyProgressRing.swift
Normal file
45
Conjuga/Conjuga/Views/Components/DailyProgressRing.swift
Normal 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()
|
||||
}
|
||||
53
Conjuga/Conjuga/Views/Components/HandwritingCanvas.swift
Normal file
53
Conjuga/Conjuga/Views/Components/HandwritingCanvas.swift
Normal 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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
134
Conjuga/Conjuga/Views/Components/TensePill.swift
Normal file
134
Conjuga/Conjuga/Views/Components/TensePill.swift
Normal 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)
|
||||
}
|
||||
526
Conjuga/Conjuga/Views/Course/CourseQuizView.swift
Normal file
526
Conjuga/Conjuga/Views/Course/CourseQuizView.swift
Normal 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)
|
||||
}
|
||||
160
Conjuga/Conjuga/Views/Course/CourseView.swift
Normal file
160
Conjuga/Conjuga/Views/Course/CourseView.swift
Normal 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)
|
||||
}
|
||||
151
Conjuga/Conjuga/Views/Course/DeckStudyView.swift
Normal file
151
Conjuga/Conjuga/Views/Course/DeckStudyView.swift
Normal 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)
|
||||
}
|
||||
198
Conjuga/Conjuga/Views/Course/VocabFlashcardView.swift
Normal file
198
Conjuga/Conjuga/Views/Course/VocabFlashcardView.swift
Normal 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)
|
||||
}
|
||||
235
Conjuga/Conjuga/Views/Course/WeekTestView.swift
Normal file
235
Conjuga/Conjuga/Views/Course/WeekTestView.swift
Normal 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)
|
||||
}
|
||||
201
Conjuga/Conjuga/Views/Dashboard/DashboardView.swift
Normal file
201
Conjuga/Conjuga/Views/Dashboard/DashboardView.swift
Normal 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)
|
||||
}
|
||||
81
Conjuga/Conjuga/Views/Dashboard/StreakCalendarView.swift
Normal file
81
Conjuga/Conjuga/Views/Dashboard/StreakCalendarView.swift
Normal 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
|
||||
}
|
||||
298
Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift
Normal file
298
Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
544
Conjuga/Conjuga/Views/Guide/GuideView.swift
Normal file
544
Conjuga/Conjuga/Views/Guide/GuideView.swift
Normal 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)
|
||||
}
|
||||
30
Conjuga/Conjuga/Views/MainTabView.swift
Normal file
30
Conjuga/Conjuga/Views/MainTabView.swift
Normal 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()
|
||||
}
|
||||
141
Conjuga/Conjuga/Views/Onboarding/OnboardingView.swift
Normal file
141
Conjuga/Conjuga/Views/Onboarding/OnboardingView.swift
Normal 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)
|
||||
}
|
||||
139
Conjuga/Conjuga/Views/Practice/AnswerReviewView.swift
Normal file
139
Conjuga/Conjuga/Views/Practice/AnswerReviewView.swift
Normal 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()
|
||||
}
|
||||
98
Conjuga/Conjuga/Views/Practice/FlashcardView.swift
Normal file
98
Conjuga/Conjuga/Views/Practice/FlashcardView.swift
Normal 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)
|
||||
}
|
||||
352
Conjuga/Conjuga/Views/Practice/FullTableView.swift
Normal file
352
Conjuga/Conjuga/Views/Practice/FullTableView.swift
Normal 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)
|
||||
}
|
||||
153
Conjuga/Conjuga/Views/Practice/HandwritingView.swift
Normal file
153
Conjuga/Conjuga/Views/Practice/HandwritingView.swift
Normal 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)
|
||||
}
|
||||
129
Conjuga/Conjuga/Views/Practice/MultipleChoiceView.swift
Normal file
129
Conjuga/Conjuga/Views/Practice/MultipleChoiceView.swift
Normal 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)
|
||||
}
|
||||
37
Conjuga/Conjuga/Views/Practice/PracticeHeaderView.swift
Normal file
37
Conjuga/Conjuga/Views/Practice/PracticeHeaderView.swift
Normal 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()
|
||||
}
|
||||
322
Conjuga/Conjuga/Views/Practice/PracticeView.swift
Normal file
322
Conjuga/Conjuga/Views/Practice/PracticeView.swift
Normal 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)
|
||||
}
|
||||
452
Conjuga/Conjuga/Views/Practice/SentenceBuilderView.swift
Normal file
452
Conjuga/Conjuga/Views/Practice/SentenceBuilderView.swift
Normal 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)
|
||||
}
|
||||
114
Conjuga/Conjuga/Views/Practice/TypingView.swift
Normal file
114
Conjuga/Conjuga/Views/Practice/TypingView.swift
Normal 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)
|
||||
}
|
||||
102
Conjuga/Conjuga/Views/Settings/SettingsView.swift
Normal file
102
Conjuga/Conjuga/Views/Settings/SettingsView.swift
Normal 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)
|
||||
}
|
||||
73
Conjuga/Conjuga/Views/Verbs/VerbDetailView.swift
Normal file
73
Conjuga/Conjuga/Views/Verbs/VerbDetailView.swift
Normal 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)
|
||||
}
|
||||
100
Conjuga/Conjuga/Views/Verbs/VerbListView.swift
Normal file
100
Conjuga/Conjuga/Views/Verbs/VerbListView.swift
Normal 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)
|
||||
}
|
||||
1
Conjuga/Conjuga/conjuga_data.json
Normal file
1
Conjuga/Conjuga/conjuga_data.json
Normal file
File diff suppressed because one or more lines are too long
1
Conjuga/Conjuga/course_data.json
Normal file
1
Conjuga/Conjuga/course_data.json
Normal file
File diff suppressed because one or more lines are too long
230
Conjuga/ConjugaWidget/CombinedWidget.swift
Normal file
230
Conjuga/ConjugaWidget/CombinedWidget.swift
Normal 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
|
||||
)
|
||||
}
|
||||
10
Conjuga/ConjugaWidget/ConjugaWidget.entitlements
Normal file
10
Conjuga/ConjugaWidget/ConjugaWidget.entitlements
Normal 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>
|
||||
12
Conjuga/ConjugaWidget/ConjugaWidgetBundle.swift
Normal file
12
Conjuga/ConjugaWidget/ConjugaWidgetBundle.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ConjugaWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
DailyProgressWidget()
|
||||
WordOfDayWidget()
|
||||
WeekProgressWidget()
|
||||
CombinedWidget()
|
||||
}
|
||||
}
|
||||
102
Conjuga/ConjugaWidget/DailyProgressWidget.swift
Normal file
102
Conjuga/ConjugaWidget/DailyProgressWidget.swift
Normal 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)
|
||||
}
|
||||
29
Conjuga/ConjugaWidget/Info.plist
Normal file
29
Conjuga/ConjugaWidget/Info.plist
Normal 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>
|
||||
15
Conjuga/ConjugaWidget/NewWordIntent.swift
Normal file
15
Conjuga/ConjugaWidget/NewWordIntent.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
153
Conjuga/ConjugaWidget/WeekProgressWidget.swift
Normal file
153
Conjuga/ConjugaWidget/WeekProgressWidget.swift
Normal 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)
|
||||
}
|
||||
16
Conjuga/ConjugaWidget/WidgetDataReader.swift
Normal file
16
Conjuga/ConjugaWidget/WidgetDataReader.swift
Normal 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
|
||||
}
|
||||
}
|
||||
201
Conjuga/ConjugaWidget/WordOfDayWidget.swift
Normal file
201
Conjuga/ConjugaWidget/WordOfDayWidget.swift
Normal 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
19
Conjuga/Package.swift
Normal 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")
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
47501
Conjuga/Scripts/all_courses_data.json
Normal file
47501
Conjuga/Scripts/all_courses_data.json
Normal file
File diff suppressed because it is too large
Load Diff
14
Conjuga/Scripts/build_store.swift
Normal file
14
Conjuga/Scripts/build_store.swift
Normal 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.")
|
||||
1
Conjuga/Scripts/conjuga_data.json
Normal file
1
Conjuga/Scripts/conjuga_data.json
Normal file
File diff suppressed because one or more lines are too long
160
Conjuga/Scripts/conjuga_data_debug.json
Normal file
160
Conjuga/Scripts/conjuga_data_debug.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
14420
Conjuga/Scripts/course_data.json
Normal file
14420
Conjuga/Scripts/course_data.json
Normal file
File diff suppressed because it is too large
Load Diff
7276
Conjuga/Scripts/course_examples.json
Normal file
7276
Conjuga/Scripts/course_examples.json
Normal file
File diff suppressed because it is too large
Load Diff
550
Conjuga/Scripts/merge_data.py
Normal file
550
Conjuga/Scripts/merge_data.py
Normal 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", "tú", "é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", "ió", "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", "ió", "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)")
|
||||
453
Conjuga/Scripts/scrape_all_courses.py
Normal file
453
Conjuga/Scripts/scrape_all_courses.py
Normal 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())
|
||||
238
Conjuga/Scripts/scrape_brainscape.py
Normal file
238
Conjuga/Scripts/scrape_brainscape.py
Normal 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())
|
||||
166
Conjuga/Scripts/scrape_examples.py
Normal file
166
Conjuga/Scripts/scrape_examples.py
Normal 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())
|
||||
13
Conjuga/SharedModels/Package.swift
Normal file
13
Conjuga/SharedModels/Package.swift
Normal 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"),
|
||||
]
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
24
Conjuga/SharedModels/Sources/SharedModels/CourseDeck.swift
Normal file
24
Conjuga/SharedModels/Sources/SharedModels/CourseDeck.swift
Normal 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
|
||||
}
|
||||
}
|
||||
28
Conjuga/SharedModels/Sources/SharedModels/VocabCard.swift
Normal file
28
Conjuga/SharedModels/Sources/SharedModels/VocabCard.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
84
Conjuga/project.yml
Normal 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
227
app_features.md
Normal 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 (1–5 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 1–3 (83 verbs)
|
||||
- Intermediate 1–4 (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
|
||||
Reference in New Issue
Block a user