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>
This commit is contained in:
Trey t
2026-04-10 14:04:45 -05:00
parent fd5861c48d
commit f59d81fc5a
5 changed files with 89 additions and 66 deletions

View File

@@ -20,28 +20,32 @@ struct WidgetDataService {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext) let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
// Mirror the user's selected verb level into shared UserDefaults so the
// widget (a separate process with no access to the cloud/CloudKit store)
// can read it when picking the word of the day.
shared.set(progress.selectedLevel, forKey: "selectedVerbLevel")
let now = Date() let now = Date()
let dueDescriptor = FetchDescriptor<ReviewCard>( let dueDescriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.dueDate <= now } predicate: #Predicate<ReviewCard> { $0.dueDate <= now }
) )
let dueCount = (try? cloudContext.fetchCount(dueDescriptor)) ?? 0 let dueCount = (try? cloudContext.fetchCount(dueDescriptor)) ?? 0
// Cache a verb-based word-of-the-day for the CombinedWidget's stats
// fallback path. Widgets that display the word directly call
// VerbStore.fetchVerbOfDay themselves.
var wordOfDay: WordOfDay? var wordOfDay: WordOfDay?
let wordOffset = shared.integer(forKey: "wordOffset") let wordOffset = shared.integer(forKey: "wordOffset")
if let card = CourseCardStore.fetchWordOfDayCard( if let verb = VerbStore.fetchVerbOfDay(
for: now, for: now,
wordOffset: wordOffset, dayOffset: wordOffset,
selectedLevel: progress.selectedLevel,
context: localContext context: localContext
) { ) {
let deckId = card.deckId
let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId }
)
let deck = (try? localContext.fetch(deckDescriptor))?.first
wordOfDay = WordOfDay( wordOfDay = WordOfDay(
spanish: card.front, spanish: verb.infinitive,
english: card.back, english: verb.english,
weekNumber: deck?.weekNumber ?? 1 subtitle: "Level: \(progress.selectedLevel.capitalized)"
) )
} }

View File

@@ -10,7 +10,7 @@ struct CombinedEntry: TimelineEntry {
} }
struct CombinedProvider: TimelineProvider { struct CombinedProvider: TimelineProvider {
private static let previewWord = WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1) private static let previewWord = WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic")
func placeholder(in context: Context) -> CombinedEntry { func placeholder(in context: Context) -> CombinedEntry {
CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder) CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder)
@@ -60,29 +60,22 @@ struct CombinedProvider: TimelineProvider {
) else { return nil } ) else { return nil }
let context = ModelContext(container) let context = ModelContext(container)
let wordOffset = UserDefaults(suiteName: "group.com.conjuga.app")?.integer(forKey: "wordOffset") ?? 0 let defaults = UserDefaults(suiteName: "group.com.conjuga.app")
guard let card = CourseCardStore.fetchWordOfDayCard(for: date, wordOffset: wordOffset, context: context) else { let wordOffset = defaults?.integer(forKey: "wordOffset") ?? 0
return nil let selectedLevel = defaults?.string(forKey: "selectedVerbLevel") ?? "basic"
}
let deckId = card.deckId guard let verb = VerbStore.fetchVerbOfDay(
let deckDescriptor = FetchDescriptor<CourseDeck>( for: date,
predicate: #Predicate<CourseDeck> { $0.id == deckId } dayOffset: wordOffset,
selectedLevel: selectedLevel,
context: context
) else { return nil }
return WordOfDay(
spanish: verb.infinitive,
english: verb.english,
subtitle: "Level: \(selectedLevel.capitalized)"
) )
let deck = (try? context.fetch(deckDescriptor))?.first
let week = deck?.weekNumber ?? 1
// If the deck is reversed (English on front), swap so spanish is always Spanish.
let spanish: String
let english: String
if deck?.isReversed == true {
spanish = card.back
english = card.front
} else {
spanish = card.front
english = card.back
}
return WordOfDay(spanish: spanish, english: english, weekNumber: week)
} }
} }
@@ -123,7 +116,7 @@ struct CombinedWidgetView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
HStack(spacing: 12) { HStack(spacing: 12) {
Text("Week \(word.weekNumber)") Text(word.subtitle)
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
@@ -233,7 +226,7 @@ struct CombinedWidgetView: View {
} timeline: { } timeline: {
CombinedEntry( CombinedEntry(
date: Date(), date: Date(),
word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1), word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"),
data: .placeholder data: .placeholder
) )
} }

View File

@@ -10,12 +10,12 @@ struct WordOfDayEntry: TimelineEntry {
struct WordOfDayProvider: TimelineProvider { struct WordOfDayProvider: TimelineProvider {
func placeholder(in context: Context) -> WordOfDayEntry { func placeholder(in context: Context) -> WordOfDayEntry {
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)) WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"))
} }
func getSnapshot(in context: Context, completion: @escaping (WordOfDayEntry) -> Void) { func getSnapshot(in context: Context, completion: @escaping (WordOfDayEntry) -> Void) {
if context.isPreview { if context.isPreview {
completion(WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1))) completion(WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic")))
return return
} }
completion(WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date()))) completion(WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date())))
@@ -51,28 +51,22 @@ struct WordOfDayProvider: TimelineProvider {
) else { return nil } ) else { return nil }
let context = ModelContext(container) let context = ModelContext(container)
let wordOffset = UserDefaults(suiteName: "group.com.conjuga.app")?.integer(forKey: "wordOffset") ?? 0 let defaults = UserDefaults(suiteName: "group.com.conjuga.app")
guard let card = CourseCardStore.fetchWordOfDayCard(for: date, wordOffset: wordOffset, context: context) else { let wordOffset = defaults?.integer(forKey: "wordOffset") ?? 0
return nil let selectedLevel = defaults?.string(forKey: "selectedVerbLevel") ?? "basic"
}
let deckId = card.deckId
let descriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId }
)
let deck = (try? context.fetch(descriptor))?.first
let week = deck?.weekNumber ?? 1
// If the deck is reversed (English on front), swap so spanish is always Spanish. guard let verb = VerbStore.fetchVerbOfDay(
let spanish: String for: date,
let english: String dayOffset: wordOffset,
if deck?.isReversed == true { selectedLevel: selectedLevel,
spanish = card.back context: context
english = card.front ) else { return nil }
} else {
spanish = card.front return WordOfDay(
english = card.back spanish: verb.infinitive,
} english: verb.english,
return WordOfDay(spanish: spanish, english: english, weekNumber: week) subtitle: "Level: \(selectedLevel.capitalized)"
)
} }
} }
@@ -135,7 +129,7 @@ struct WordOfDayWidgetView: View {
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text("Week \(word.weekNumber)") Text(word.subtitle)
.font(.caption2) .font(.caption2)
.foregroundStyle(.orange) .foregroundStyle(.orange)
} }
@@ -168,9 +162,9 @@ struct WordOfDayWidgetView: View {
} }
HStack { HStack {
Image(systemName: "calendar") Image(systemName: "tag")
.font(.caption2) .font(.caption2)
Text("Week \(word.weekNumber)") Text(word.subtitle)
.font(.caption2) .font(.caption2)
} }
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -184,11 +178,11 @@ struct WordOfDayWidgetView: View {
#Preview(as: .systemSmall) { #Preview(as: .systemSmall) {
WordOfDayWidget() WordOfDayWidget()
} timeline: { } timeline: {
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)) WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"))
} }
#Preview(as: .systemMedium) { #Preview(as: .systemMedium) {
WordOfDayWidget() WordOfDayWidget()
} timeline: { } timeline: {
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)) WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"))
} }

View File

@@ -0,0 +1,32 @@
import Foundation
import SwiftData
public enum VerbStore {
/// Deterministically pick a `Verb` for the given date, filtered by the user's
/// selected level (which expands to the set of underlying data levels).
/// Returns nil if there are no matching verbs.
public static func fetchVerbOfDay(
for date: Date,
dayOffset: Int,
selectedLevel: String,
context: ModelContext
) -> Verb? {
let allowedLevels = Array(VerbLevelGroup.dataLevels(for: selectedLevel))
var descriptor = FetchDescriptor<Verb>(
predicate: #Predicate<Verb> { verb in
allowedLevels.contains(verb.level)
},
sortBy: [SortDescriptor(\Verb.rank), SortDescriptor(\Verb.infinitive)]
)
let count = (try? context.fetchCount(descriptor)) ?? 0
guard count > 0 else { return nil }
let dayOfYear = Calendar.current.ordinality(of: .day, in: .year, for: date) ?? 1
let index = (dayOfYear + dayOffset) % count
descriptor.fetchOffset = index
descriptor.fetchLimit = 1
return (try? context.fetch(descriptor))?.first
}
}

View File

@@ -3,12 +3,12 @@ import Foundation
public struct WordOfDay: Codable, Equatable, Sendable { public struct WordOfDay: Codable, Equatable, Sendable {
public var spanish: String public var spanish: String
public var english: String public var english: String
public var weekNumber: Int public var subtitle: String
public init(spanish: String, english: String, weekNumber: Int) { public init(spanish: String, english: String, subtitle: String) {
self.spanish = spanish self.spanish = spanish
self.english = english self.english = english
self.weekNumber = weekNumber self.subtitle = subtitle
} }
} }
@@ -50,7 +50,7 @@ public struct WidgetData: Codable, Equatable, Sendable {
dailyGoal: 50, dailyGoal: 50,
currentStreak: 3, currentStreak: 3,
dueCardCount: 8, dueCardCount: 8,
wordOfTheDay: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1), wordOfTheDay: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"),
latestTestScore: 85, latestTestScore: 85,
latestTestWeek: 2, latestTestWeek: 2,
currentWeek: 2, currentWeek: 2,