Files
Spanish/Conjuga/ConjugaWidget/CombinedWidget.swift
Trey t 8e1c9b6bf1 Make textbook data self-heal after widget schema wipes
Root cause of the repeatedly-disappearing textbook: both widget timeline
providers were opening the shared local SwiftData store with a schema
that omitted TextbookChapter. On each widget refresh SwiftData
destructively migrated the store to match the widget's narrower schema,
dropping the ZTEXTBOOKCHAPTER rows (and sometimes the table itself).
The app then re-created an empty table on next open, but
refreshTextbookDataIfNeeded skipped re-seeding because the UserDefaults
version flag was already current — leaving the store empty indefinitely.

Three changes:

1. Widgets (CombinedWidget, WordOfDayWidget): added TextbookChapter to
   both schema arrays so they match the main app. Widget refreshes will
   no longer drop the entity.

2. DataLoader.refreshTextbookDataIfNeeded: trigger now considers BOTH
   the version flag and the actual on-disk row count. If rows are
   missing for any reason (past wipes, future subset-schema openers,
   corruption), the next launch re-seeds. Eliminates the class of bug
   where a version flag lies about what's really in the store.

3. StoreInspector: reports ZTEXTBOOKCHAPTER row count alongside the
   other entities so we can confirm state from logs.

Bumped textbookDataVersion to 12 so devices that were stuck in the
silent-failure state re-seed on next launch regardless of prior flag
value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:31:14 -05:00

236 lines
8.3 KiB
Swift

import WidgetKit
import SwiftUI
import SwiftData
import SharedModels
struct CombinedEntry: TimelineEntry {
let date: Date
let word: WordOfDay?
let data: WidgetData
}
struct CombinedProvider: TimelineProvider {
private static let previewWord = WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic")
func placeholder(in context: Context) -> CombinedEntry {
CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder)
}
func getSnapshot(in context: Context, completion: @escaping (CombinedEntry) -> Void) {
if context.isPreview {
completion(CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder))
return
}
let word = fetchWordOfDay(for: Date())
let data = WidgetDataReader.read()
completion(CombinedEntry(date: Date(), word: word, data: data))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CombinedEntry>) -> Void) {
let word = fetchWordOfDay(for: Date())
let data = WidgetDataReader.read()
let entry = CombinedEntry(date: Date(), word: word, data: data)
// Expire at midnight for new word
let tomorrow = Calendar.current.startOfDay(
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
)
completion(Timeline(entries: [entry], policy: .after(tomorrow)))
}
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
guard let localURL = SharedStore.localStoreURL() else { return nil }
// MUST declare all 7 local entities to match the main app's schema.
// Declaring a subset would cause SwiftData to destructively migrate the
// store on open, dropping the entities not listed here (this is how we
// previously lost all TextbookChapter rows on every widget refresh).
let config = ModelConfiguration(
"local",
schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
]),
url: localURL,
cloudKitDatabase: .none
)
guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self,
configurations: config
) else { return nil }
let context = ModelContext(container)
let defaults = UserDefaults(suiteName: "group.com.conjuga.app")
let wordOffset = defaults?.integer(forKey: "wordOffset") ?? 0
let selectedLevel = defaults?.string(forKey: "selectedVerbLevel") ?? "basic"
guard let verb = VerbStore.fetchVerbOfDay(
for: date,
dayOffset: wordOffset,
selectedLevel: selectedLevel,
context: context
) else { return nil }
return WordOfDay(
spanish: verb.infinitive,
english: verb.english,
subtitle: "Level: \(selectedLevel.capitalized)"
)
}
}
struct CombinedWidget: Widget {
let kind = "CombinedWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CombinedProvider()) { entry in
CombinedWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Conjuga Overview")
.description("Word of the day with daily stats and progress.")
.supportedFamilies([.systemLarge])
}
}
struct CombinedWidgetView: View {
let entry: CombinedEntry
var body: some View {
VStack(spacing: 16) {
// Word of the Day section
if let word = entry.word {
VStack(spacing: 6) {
Text("WORD OF THE DAY")
.font(.caption2.weight(.bold))
.foregroundStyle(.orange)
.tracking(1.5)
Text(word.spanish)
.font(.largeTitle.bold())
.minimumScaleFactor(0.5)
.lineLimit(1)
Text(word.english)
.font(.title3)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Text(word.subtitle)
.font(.caption2)
.foregroundStyle(.tertiary)
Button(intent: NewWordIntent()) {
Label("New Word", systemImage: "arrow.triangle.2.circlepath")
.font(.caption2)
}
.tint(.orange)
}
}
.frame(maxWidth: .infinity)
}
Divider()
// Stats grid
HStack(spacing: 0) {
// Daily progress
VStack(spacing: 6) {
ZStack {
Circle()
.stroke(.quaternary, lineWidth: 5)
Circle()
.trim(from: 0, to: entry.data.progressPercent)
.stroke(.orange, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 0) {
Text("\(entry.data.todayCount)")
.font(.title3.bold().monospacedDigit())
Text("/\(entry.data.dailyGoal)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(width: 60, height: 60)
Text("Today")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
// Streak
VStack(spacing: 6) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundStyle(.orange)
Text("\(entry.data.currentStreak)")
.font(.title3.bold().monospacedDigit())
Text("Streak")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
// Due cards
VStack(spacing: 6) {
Image(systemName: "clock.badge.exclamationmark")
.font(.title)
.foregroundStyle(entry.data.dueCardCount > 0 ? .blue : .secondary)
Text("\(entry.data.dueCardCount)")
.font(.title3.bold().monospacedDigit())
Text("Due")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
// Test score
VStack(spacing: 6) {
Image(systemName: entry.data.latestTestScore ?? 0 >= 90 ? "star.fill" : "pencil.and.list.clipboard")
.font(.title)
.foregroundStyle(scoreColor)
if let score = entry.data.latestTestScore {
Text("\(score)%")
.font(.title3.bold().monospacedDigit())
} else {
Text("")
.font(.title3.bold())
}
Text("Test")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 8)
}
private var scoreColor: Color {
guard let score = entry.data.latestTestScore else { return .secondary }
if score >= 90 { return .yellow }
if score >= 70 { return .green }
return .orange
}
}
#Preview(as: .systemLarge) {
CombinedWidget()
} timeline: {
CombinedEntry(
date: Date(),
word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"),
data: .placeholder
)
}