Files
Spanish/Conjuga/Conjuga/ConjugaApp.swift
T
Trey T 7da98d786c Vocab study — noun & adjective flashcards with CEFR level toggles
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>
2026-05-19 20:16:55 -05:00

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)
}
}