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,13 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "SharedModels",
platforms: [.iOS(.v18)],
products: [
.library(name: "SharedModels", targets: ["SharedModels"]),
],
targets: [
.target(name: "SharedModels"),
]
)

View File

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

View File

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

View File

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

View File

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