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>
236 lines
8.3 KiB
Swift
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
|
|
)
|
|
}
|