Files
Spanish/Conjuga/ConjugaWidget/WordOfDayWidget.swift
Trey t f59d81fc5a Widget word-of-day picks from master verb list filtered by user level
Previously the widget was picking from course VocabCards, which could
land on any course week and was showing unrelated phrases instead of the
verbs the user is actually studying.

Now the widget uses a new VerbStore.fetchVerbOfDay helper that:
- Expands the user's selectedLevel via VerbLevelGroup.dataLevels
- Runs a FetchDescriptor<Verb> filtered by those levels, sorted by rank
- Uses fetchCount + fetchOffset for a deterministic daily pick

The main app mirrors UserProgress.selectedLevel into the shared app
group UserDefaults (key "selectedVerbLevel") on every WidgetDataService
update, so the widget process can read it without touching the cloud
store.

WordOfDay.weekNumber was replaced with a more flexible subtitle: String
so widgets can display "Level: Basic" instead of course week numbers.

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

189 lines
6.2 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 }
// MUST declare all 6 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.
let config = ModelConfiguration(
"local",
schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
url: localURL,
cloudKitDatabase: .none
)
guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.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 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"))
}