Initial commit: Conjuga Spanish conjugation app

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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