26ce662c60
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>
187 lines
6.1 KiB
Swift
187 lines
6.1 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
import SwiftData
|
|
import SharedModels
|
|
|
|
struct WordOfDayEntry: TimelineEntry {
|
|
let date: Date
|
|
let word: WordOfDay?
|
|
}
|
|
|
|
struct WordOfDayProvider: TimelineProvider {
|
|
func placeholder(in context: Context) -> WordOfDayEntry {
|
|
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"))
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (WordOfDayEntry) -> Void) {
|
|
if context.isPreview {
|
|
completion(WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic")))
|
|
return
|
|
}
|
|
completion(WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date())))
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<WordOfDayEntry>) -> Void) {
|
|
let entry = WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date()))
|
|
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 WordOfDayWidget: Widget {
|
|
let kind = "WordOfDayWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: kind, provider: WordOfDayProvider()) { entry in
|
|
WordOfDayWidgetView(entry: entry)
|
|
.containerBackground(.fill.tertiary, for: .widget)
|
|
}
|
|
.configurationDisplayName("Word of the Day")
|
|
.description("Learn a new Spanish word every day.")
|
|
.supportedFamilies([.systemSmall, .systemMedium])
|
|
}
|
|
}
|
|
|
|
struct WordOfDayWidgetView: View {
|
|
@Environment(\.widgetFamily) var family
|
|
let entry: WordOfDayEntry
|
|
|
|
var body: some View {
|
|
if let word = entry.word {
|
|
switch family {
|
|
case .systemSmall:
|
|
smallView(word: word)
|
|
case .systemMedium:
|
|
mediumView(word: word)
|
|
default:
|
|
smallView(word: word)
|
|
}
|
|
} else {
|
|
VStack {
|
|
Image(systemName: "textformat.abc")
|
|
.font(.title)
|
|
.foregroundStyle(.secondary)
|
|
Text("Open Conjuga to start")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func smallView(word: WordOfDay) -> some View {
|
|
VStack(spacing: 8) {
|
|
Text("Word of the Day")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.textCase(.uppercase)
|
|
|
|
Text(word.spanish)
|
|
.font(.title2.bold())
|
|
.minimumScaleFactor(0.6)
|
|
.lineLimit(1)
|
|
|
|
Text(word.english)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.minimumScaleFactor(0.6)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text(word.subtitle)
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
|
|
private func mediumView(word: WordOfDay) -> some View {
|
|
HStack(spacing: 16) {
|
|
VStack(spacing: 4) {
|
|
Text("WORD OF THE DAY")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.orange)
|
|
|
|
Text(word.spanish)
|
|
.font(.largeTitle.bold())
|
|
.minimumScaleFactor(0.5)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("English")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
Text(word.english)
|
|
.font(.headline)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
HStack {
|
|
Image(systemName: "tag")
|
|
.font(.caption2)
|
|
Text(word.subtitle)
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.horizontal, 4)
|
|
}
|
|
}
|
|
|
|
#Preview(as: .systemSmall) {
|
|
WordOfDayWidget()
|
|
} timeline: {
|
|
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"))
|
|
}
|
|
|
|
#Preview(as: .systemMedium) {
|
|
WordOfDayWidget()
|
|
} timeline: {
|
|
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"))
|
|
}
|