Files
Spanish/Conjuga/ConjugaWidget/CombinedWidget.swift
T
Trey T 26ce662c60 Fix widget wiping the Books tables on every refresh
The widget extension opened the shared local SwiftData store with a
7-entity schema while the app's store has 10. SwiftData treats the
smaller schema as a migration and destructively drops the unlisted
tables — so every widget refresh deleted the bundled Book/BookChapter
rows (and DownloadedVideo), which is why books vanished after reinstalls.

Introduce SharedStore.localSchemaModels as the single source of truth
for the local schema and build the app and both widget containers from
it, so app and widget can no longer drift apart. The same class of bug
hit TextbookChapter previously; a shared list prevents a third recurrence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:12:51 -05:00

231 lines
8.1 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 }
// Open the store with the SAME schema as the main app. A subset schema
// would make SwiftData destructively migrate the store on open and drop
// every unlisted table (this is how widget refreshes kept wiping the
// bundled Book rows, and TextbookChapter before them).
let schema = Schema(SharedStore.localSchemaModels)
let config = ModelConfiguration(
"local",
schema: schema,
url: localURL,
cloudKitDatabase: .none
)
guard let container = try? ModelContainer(
for: schema,
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
)
}