7da98d786c
Add SRS-driven noun and adjective flashcards modeled on the existing verb flashcard flow: - SharedModels/Lexeme — catalog of non-verb vocab, frequency-ranked, with gender for nouns and optional example sentences. Seeded from a bundled vocab_lexemes.json built by Scripts/vocab/build_lexemes.py, which joins frequency.csv + es-en.data from a pinned doozan/spanish_data commit (CC-BY-SA: hermitdave/FrequencyWords + Wiktionary). 1,449 nouns and 600 adjectives, each with Wiktionary-sourced gender and (where available) an example sentence with English translation. - LexemeReviewCard + LexemeReviewStore — cloud-synced SM-2 SRS, keyed by partOfSpeech + lexemeId + drillMode so future drill modes can coexist. - LexemeSessionQueue + LexemePool — parallel to VocabSessionQueue; fresh cards sort by frequency rank. - LexemeStudyGroup — cloud-synced resumable session per (partOfSpeech, drillMode). - NounFlashcardPracticeView + AdjectiveFlashcardPracticeView — same flow as VocabFlashcardPracticeView: English prompt → tap to reveal Spanish → Again/Hard/Good/Easy. Nouns reveal with their article (la taza, el problema) so gender is taught alongside meaning, not as a separate quiz. Example sentence shown when present. CEFR-style level toggles: - LexemeLevel enum (A1/A2/B1/B2/C1+) derived from frequencyRank with standard Spanish-frequency-dictionary cutoffs (250/500/1000/2000). - UserProgress.selectedLexemeLevels — cloud-synced multi-select, defaults to A1+A2 on first launch. - SettingsView gains a "Vocabulary Levels" section with five toggles; the existing "Levels" section is renamed "Verb Levels" for clarity. - Due SRS cards always surface regardless of toggles. Disabling a level only stops new cards from that band entering the pool. PracticeView gets "Nouns" and "Adjectives" rows under "Books". DataLoader: new lexemeDataVersion gate that re-seeds the Lexeme table from vocab_lexemes.json independent of book seeding. project.yml lists the new JSON resource and the existing book_olly-vol2.json (which the previous build was silently excluding because xcodegen rewrote the project from project.yml). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
275 lines
11 KiB
Swift
275 lines
11 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
import SwiftData
|
|
import WidgetKit
|
|
import BackgroundTasks
|
|
|
|
@MainActor
|
|
private enum CloudPreviewContainer {
|
|
static let value: ModelContainer = {
|
|
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
|
return try! ModelContainer(
|
|
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
|
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
|
configurations: configuration
|
|
)
|
|
}()
|
|
}
|
|
|
|
typealias CloudModelContextProvider = @MainActor @Sendable () -> ModelContext
|
|
private let appRefreshTaskIdentifier = "com.conjuga.app.refresh"
|
|
|
|
private struct CloudModelContextProviderKey: EnvironmentKey {
|
|
static let defaultValue: CloudModelContextProvider = {
|
|
CloudPreviewContainer.value.mainContext
|
|
}
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var cloudModelContextProvider: CloudModelContextProvider {
|
|
get { self[CloudModelContextProviderKey.self] }
|
|
set { self[CloudModelContextProviderKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
@main
|
|
struct ConjugaApp: App {
|
|
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@State private var isReady = true
|
|
@State private var syncMonitor = SyncStatusMonitor()
|
|
@State private var studyTimer = StudyTimerService()
|
|
@State private var dictionary = DictionaryService()
|
|
@State private var verbExampleCache = VerbExampleCache()
|
|
@State private var reflexiveStore = ReflexiveVerbStore()
|
|
@State private var youtubeVideoStore = YouTubeVideoStore()
|
|
|
|
let localContainer: ModelContainer
|
|
let cloudContainer: ModelContainer
|
|
|
|
init() {
|
|
guard let localURL = SharedStore.localStoreURL() else {
|
|
fatalError("App group 'group.com.conjuga.app' is not accessible. Check entitlements and provisioning profile.")
|
|
}
|
|
|
|
// One-time force-reset of the local store to clear stale schema metadata
|
|
// accumulated from previous container configurations.
|
|
Self.performOneTimeLocalStoreResetIfNeeded(at: localURL)
|
|
|
|
// DIAGNOSTIC: what's in the store file BEFORE we open it via SwiftData?
|
|
StoreInspector.dump(at: localURL, label: "before-open")
|
|
|
|
do {
|
|
localContainer = try Self.makeValidatedLocalContainer(at: localURL)
|
|
SharedStore.localContainer = localContainer
|
|
|
|
// DIAGNOSTIC: what's in the store file AFTER SwiftData opened it?
|
|
StoreInspector.dump(at: localURL, label: "after-open")
|
|
print("[ConjugaApp] localContainer identity: \(ObjectIdentifier(localContainer))")
|
|
|
|
let cloudConfig = ModelConfiguration(
|
|
"cloud",
|
|
schema: Schema([
|
|
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
|
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
|
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
|
LexemeReviewCard.self, LexemeStudyGroup.self,
|
|
]),
|
|
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
|
)
|
|
cloudContainer = try ModelContainer(
|
|
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
|
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
|
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
|
LexemeReviewCard.self, LexemeStudyGroup.self,
|
|
configurations: 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()
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if syncMonitor.shouldShowToast {
|
|
SyncToast()
|
|
.padding(.bottom, 100)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
}
|
|
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
|
|
.environment(syncMonitor)
|
|
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
|
.environment(studyTimer)
|
|
.environment(dictionary)
|
|
.environment(verbExampleCache)
|
|
.environment(reflexiveStore)
|
|
.environment(youtubeVideoStore)
|
|
.task {
|
|
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
|
|
if needsSeed {
|
|
isReady = false
|
|
}
|
|
|
|
await StartupCoordinator.bootstrap(localContainer: localContainer)
|
|
|
|
if !isReady {
|
|
isReady = true
|
|
}
|
|
|
|
Task { @MainActor in
|
|
dictionary.buildIfNeeded(context: localContainer.mainContext)
|
|
}
|
|
|
|
Task { @MainActor in
|
|
syncMonitor.beginSync()
|
|
await StartupCoordinator.runMaintenance(
|
|
localContainer: localContainer,
|
|
cloudContainer: cloudContainer
|
|
)
|
|
// Reset a broken streak immediately on launch so the
|
|
// dashboard never shows a stale number even if the user
|
|
// hasn't navigated to it yet.
|
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContainer.mainContext)
|
|
progress.validateStreakIfStale(context: cloudContainer.mainContext)
|
|
WidgetDataService.update(
|
|
localContainer: localContainer,
|
|
cloudContainer: cloudContainer
|
|
)
|
|
syncMonitor.endSync()
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
switch newPhase {
|
|
case .active:
|
|
studyTimer.start()
|
|
case .inactive, .background:
|
|
studyTimer.stop(context: cloudContainer.mainContext)
|
|
@unknown default:
|
|
break
|
|
}
|
|
|
|
if newPhase == .background {
|
|
WidgetDataService.update(
|
|
localContainer: localContainer,
|
|
cloudContainer: cloudContainer
|
|
)
|
|
Self.scheduleAppRefresh()
|
|
}
|
|
}
|
|
}
|
|
.modelContainer(localContainer)
|
|
.backgroundTask(.appRefresh(appRefreshTaskIdentifier)) {
|
|
Self.scheduleAppRefresh()
|
|
await refreshWidgetData()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func refreshWidgetData() async {
|
|
WidgetDataService.update(
|
|
localContainer: localContainer,
|
|
cloudContainer: cloudContainer
|
|
)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
|
|
nonisolated static func scheduleAppRefresh() {
|
|
let request = BGAppRefreshTaskRequest(identifier: appRefreshTaskIdentifier)
|
|
// Minimum delay — system decides actual run time based on usage patterns.
|
|
// We want the widget refreshed before the user typically opens the app.
|
|
request.earliestBeginDate = Calendar.current.startOfDay(
|
|
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
|
)
|
|
|
|
do {
|
|
try BGTaskScheduler.shared.submit(request)
|
|
} catch {
|
|
print("Failed to schedule app refresh: \(error)")
|
|
}
|
|
}
|
|
|
|
private static func makeValidatedLocalContainer(at url: URL) throws -> ModelContainer {
|
|
let container = try makeLocalContainer(at: url)
|
|
if localStoreIsUsable(container: container) {
|
|
return container
|
|
}
|
|
|
|
deleteStoreFiles(at: url)
|
|
UserDefaults.standard.removeObject(forKey: "courseDataVersion")
|
|
print("[ConjugaApp] ⚠️ Reset corrupted local reference store — this triggers full re-seed")
|
|
|
|
return try makeLocalContainer(at: url)
|
|
}
|
|
|
|
private static func makeLocalContainer(at url: URL) throws -> ModelContainer {
|
|
// Built from the single shared model list so the app and the widget
|
|
// extension always open the store with an identical schema.
|
|
let schema = Schema(SharedStore.localSchemaModels)
|
|
let localConfig = ModelConfiguration(
|
|
"local",
|
|
schema: schema,
|
|
url: url,
|
|
cloudKitDatabase: .none
|
|
)
|
|
return try ModelContainer(for: schema, configurations: localConfig)
|
|
}
|
|
|
|
private static func localStoreIsUsable(container: ModelContainer) -> Bool {
|
|
let context = ModelContext(container)
|
|
do {
|
|
_ = try context.fetchCount(FetchDescriptor<Verb>())
|
|
return true
|
|
} catch {
|
|
print("Local reference store validation failed: \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func deleteStoreFiles(at url: URL) {
|
|
let fileManager = FileManager.default
|
|
for suffix in ["", "-wal", "-shm"] {
|
|
let candidateURL = URL(fileURLWithPath: url.path + suffix)
|
|
guard fileManager.fileExists(atPath: candidateURL.path) else { continue }
|
|
try? fileManager.removeItem(at: candidateURL)
|
|
}
|
|
}
|
|
|
|
/// One-time nuclear reset of the local reference store.
|
|
/// Clears accumulated stale schema metadata from previous container configurations.
|
|
/// Bump the version number to force another reset if the schema changes again.
|
|
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
|
|
let resetVersion = 6 // bump: Lexeme added to local container
|
|
let key = "localStoreResetVersion"
|
|
let defaults = UserDefaults.standard
|
|
|
|
guard defaults.integer(forKey: key) < resetVersion else { return }
|
|
|
|
print("[ConjugaApp] Performing one-time local store reset (v\(resetVersion))")
|
|
deleteStoreFiles(at: url)
|
|
|
|
// Clear any version flags that gate seeding so everything re-seeds cleanly.
|
|
defaults.removeObject(forKey: "courseDataVersion")
|
|
defaults.removeObject(forKey: "courseProgressMigrationVersion")
|
|
|
|
defaults.set(resetVersion, forKey: key)
|
|
}
|
|
|
|
}
|