Files
Spanish/Conjuga/Conjuga/Services/ReviewStore.swift
Trey t fd5861c48d Move reference-data models to SharedModels to fix widget-triggered data loss
Root cause: the widget was opening the shared local.store with a 2-entity
schema (VocabCard, CourseDeck), causing SwiftData to destructively migrate
the file and drop the 4 entities the widget didn't know about (Verb,
VerbForm, IrregularSpan, TenseGuide). The main app would then re-seed on
next launch, and the cycle repeated forever.

Fix: move Verb, VerbForm, IrregularSpan, TenseGuide from the app target
into SharedModels so both the main app and the widget use the exact same
types from the same module. Both now declare all 6 local entities in their
ModelContainer, producing identical schema hashes and eliminating the
destructive migration.

Other changes bundled in this commit (accumulated during debugging):
- Split ModelContainer into localContainer + cloudContainer (no more
  CloudKit + non-CloudKit configs in one container)
- Add SharedStore.localStoreURL() helper and a global reference for
  bypass-environment fetches
- One-time store reset mechanism to wipe stale schema metadata from
  previous broken iterations
- Bootstrap/maintenance split so only seeding gates the UI; dedup and
  cloud repair run in the background
- Sync status toast that shows "Syncing" while background maintenance
  runs (network-aware, auto-dismisses)
- Background app refresh task to keep the widget word-of-day fresh
- Speaker icon on VerbDetailView for TTS
- Grammar notes navigation fix (nested NavigationStack was breaking
  detail pane on iPhone)
- Word-of-day widget swaps front/back when the deck is reversed so the
  Spanish word always shows in bold
- StoreInspector diagnostic helper for raw SQLite table inspection
- Add Conjuga scheme explicitly to project.yml so xcodegen doesn't drop it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:51:02 -05:00

211 lines
6.8 KiB
Swift

import Foundation
import SwiftData
struct ReviewStore {
static let progressID = "main"
static func defaultEnabledTenses() -> [String] {
TenseID.defaultPracticeIDs
}
static func fetchUserProgress(context: ModelContext) -> UserProgress? {
let descriptor = FetchDescriptor<UserProgress>(
predicate: #Predicate<UserProgress> { $0.id == progressID }
)
if let progress = (try? context.fetch(descriptor))?.first {
progress.migrateLegacyStorageIfNeeded()
return progress
}
let fallback = (try? context.fetch(FetchDescriptor<UserProgress>()))?.first
fallback?.migrateLegacyStorageIfNeeded()
return fallback
}
@discardableResult
static func fetchOrCreateUserProgress(context: ModelContext) -> UserProgress {
if let progress = fetchUserProgress(context: context) {
progress.id = progressID
if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = defaultEnabledTenses()
}
return progress
}
let progress = UserProgress()
progress.id = progressID
progress.enabledTenseIDs = defaultEnabledTenses()
context.insert(progress)
return progress
}
@discardableResult
static func fetchOrCreateReviewCard(
verbId: Int,
tenseId: String,
personIndex: Int,
context: ModelContext
) -> ReviewCard {
let key = ReviewCard.makeKey(verbId: verbId, tenseId: tenseId, personIndex: personIndex)
let keyedDescriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.key == key }
)
if let existing = (try? context.fetch(keyedDescriptor))?.first {
existing.refreshIdentityIfNeeded()
return existing
}
let legacyDescriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { card in
card.verbId == verbId &&
card.tenseId == tenseId &&
card.personIndex == personIndex
}
)
if let existing = (try? context.fetch(legacyDescriptor))?.first {
existing.refreshIdentityIfNeeded()
return existing
}
let newCard = ReviewCard(verbId: verbId, tenseId: tenseId, personIndex: personIndex)
context.insert(newCard)
return newCard
}
@discardableResult
static func updateProgress(
reviewIncrement: Int,
correctIncrement: Int,
context: ModelContext,
date: Date = Date()
) -> UserProgress {
let progress = fetchOrCreateUserProgress(context: context)
let todayString = DailyLog.dateString(from: date)
if progress.todayDate != todayString {
if !progress.todayDate.isEmpty {
if isConsecutiveDay(previous: progress.todayDate, current: todayString) {
progress.currentStreak += 1
} else {
progress.currentStreak = 1
}
} else {
progress.currentStreak = 1
}
progress.todayDate = todayString
progress.todayCount = 0
}
progress.todayCount += reviewIncrement
progress.totalReviewed += reviewIncrement
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
log.reviewCount += reviewIncrement
log.correctCount += correctIncrement
return progress
}
static func recordReview(
verbId: Int,
tenseId: String,
personIndex: Int,
quality: ReviewQuality,
context: ModelContext,
referenceContext: ModelContext
) -> [Badge] {
let card = fetchOrCreateReviewCard(
verbId: verbId,
tenseId: tenseId,
personIndex: personIndex,
context: context
)
applyReview(quality: quality, to: card)
let progress = updateProgress(
reviewIncrement: 1,
correctIncrement: quality.rawValue >= 3 ? 1 : 0,
context: context
)
let badges = AchievementService.checkAchievements(
progress: progress,
reviewContext: context,
referenceContext: referenceContext
)
try? context.save()
return badges
}
static func recordFullTableReview(
verbId: Int,
tenseId: String,
results: [Int: Bool],
context: ModelContext,
referenceContext: ModelContext
) -> [Badge] {
for (personIndex, isCorrect) in results {
let card = fetchOrCreateReviewCard(
verbId: verbId,
tenseId: tenseId,
personIndex: personIndex,
context: context
)
applyReview(quality: isCorrect ? .good : .again, to: card)
}
let allCorrect = results.values.allSatisfy { $0 }
let progress = updateProgress(
reviewIncrement: 1,
correctIncrement: allCorrect ? 1 : 0,
context: context
)
let badges = AchievementService.checkAchievements(
progress: progress,
reviewContext: context,
referenceContext: referenceContext
)
try? context.save()
return badges
}
static func fetchOrCreateDailyLog(dateString: String, context: ModelContext) -> DailyLog {
let descriptor = FetchDescriptor<DailyLog>(
predicate: #Predicate<DailyLog> { $0.id == dateString || $0.dateString == dateString }
)
if let existing = (try? context.fetch(descriptor))?.first {
existing.refreshIdentityIfNeeded()
return existing
}
let log = DailyLog(dateString: dateString)
context.insert(log)
return log
}
static func applyReview(quality: ReviewQuality, to card: ReviewCard) {
let result = SRSEngine.review(
quality: quality,
currentEase: card.easeFactor,
currentInterval: card.interval,
currentReps: card.repetitions
)
card.easeFactor = result.easeFactor
card.interval = result.interval
card.repetitions = result.repetitions
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
card.lastReviewDate = Date()
card.refreshIdentityIfNeeded()
}
private static func isConsecutiveDay(previous: String, current: String) -> Bool {
guard let prevDate = DailyLog.date(from: previous),
let currDate = DailyLog.date(from: current) else { return false }
let diff = Calendar.current.dateComponents([.day], from: prevDate, to: currDate)
return diff.day == 1
}
}