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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user