From f59d81fc5a35dd6953fcda30fdcd9e22dc630c99 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 10 Apr 2026 14:04:45 -0500 Subject: [PATCH] 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 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) --- .../Conjuga/Services/WidgetDataService.swift | 24 +++++---- Conjuga/ConjugaWidget/CombinedWidget.swift | 41 +++++++-------- Conjuga/ConjugaWidget/WordOfDayWidget.swift | 50 ++++++++----------- .../Sources/SharedModels/VerbStore.swift | 32 ++++++++++++ .../Sources/SharedModels/WidgetSnapshot.swift | 8 +-- 5 files changed, 89 insertions(+), 66 deletions(-) create mode 100644 Conjuga/SharedModels/Sources/SharedModels/VerbStore.swift diff --git a/Conjuga/Conjuga/Services/WidgetDataService.swift b/Conjuga/Conjuga/Services/WidgetDataService.swift index 793db76..3ec0247 100644 --- a/Conjuga/Conjuga/Services/WidgetDataService.swift +++ b/Conjuga/Conjuga/Services/WidgetDataService.swift @@ -20,28 +20,32 @@ struct WidgetDataService { 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 dueDescriptor = FetchDescriptor( predicate: #Predicate { $0.dueDate <= now } ) 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? let wordOffset = shared.integer(forKey: "wordOffset") - if let card = CourseCardStore.fetchWordOfDayCard( + if let verb = VerbStore.fetchVerbOfDay( for: now, - wordOffset: wordOffset, + dayOffset: wordOffset, + selectedLevel: progress.selectedLevel, context: localContext ) { - let deckId = card.deckId - let deckDescriptor = FetchDescriptor( - predicate: #Predicate { $0.id == deckId } - ) - let deck = (try? localContext.fetch(deckDescriptor))?.first wordOfDay = WordOfDay( - spanish: card.front, - english: card.back, - weekNumber: deck?.weekNumber ?? 1 + spanish: verb.infinitive, + english: verb.english, + subtitle: "Level: \(progress.selectedLevel.capitalized)" ) } diff --git a/Conjuga/ConjugaWidget/CombinedWidget.swift b/Conjuga/ConjugaWidget/CombinedWidget.swift index b6917fd..70c6725 100644 --- a/Conjuga/ConjugaWidget/CombinedWidget.swift +++ b/Conjuga/ConjugaWidget/CombinedWidget.swift @@ -10,7 +10,7 @@ struct CombinedEntry: TimelineEntry { } 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 { CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder) @@ -60,29 +60,22 @@ struct CombinedProvider: TimelineProvider { ) else { return nil } let context = ModelContext(container) - let wordOffset = UserDefaults(suiteName: "group.com.conjuga.app")?.integer(forKey: "wordOffset") ?? 0 - guard let card = CourseCardStore.fetchWordOfDayCard(for: date, wordOffset: wordOffset, context: context) else { - return nil - } + let defaults = UserDefaults(suiteName: "group.com.conjuga.app") + let wordOffset = defaults?.integer(forKey: "wordOffset") ?? 0 + let selectedLevel = defaults?.string(forKey: "selectedVerbLevel") ?? "basic" - let deckId = card.deckId - let deckDescriptor = FetchDescriptor( - predicate: #Predicate { $0.id == deckId } + 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)" ) - 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) HStack(spacing: 12) { - Text("Week \(word.weekNumber)") + Text(word.subtitle) .font(.caption2) .foregroundStyle(.tertiary) @@ -233,7 +226,7 @@ struct CombinedWidgetView: View { } timeline: { CombinedEntry( date: Date(), - word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1), + word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"), data: .placeholder ) } diff --git a/Conjuga/ConjugaWidget/WordOfDayWidget.swift b/Conjuga/ConjugaWidget/WordOfDayWidget.swift index d017329..e97530e 100644 --- a/Conjuga/ConjugaWidget/WordOfDayWidget.swift +++ b/Conjuga/ConjugaWidget/WordOfDayWidget.swift @@ -10,12 +10,12 @@ struct WordOfDayEntry: TimelineEntry { struct WordOfDayProvider: TimelineProvider { 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) { 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 } completion(WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date()))) @@ -51,28 +51,22 @@ struct WordOfDayProvider: TimelineProvider { ) else { return nil } let context = ModelContext(container) - let wordOffset = UserDefaults(suiteName: "group.com.conjuga.app")?.integer(forKey: "wordOffset") ?? 0 - guard let card = CourseCardStore.fetchWordOfDayCard(for: date, wordOffset: wordOffset, context: context) else { - return nil - } - let deckId = card.deckId - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == deckId } - ) - let deck = (try? context.fetch(descriptor))?.first - let week = deck?.weekNumber ?? 1 + let defaults = UserDefaults(suiteName: "group.com.conjuga.app") + let wordOffset = defaults?.integer(forKey: "wordOffset") ?? 0 + let selectedLevel = defaults?.string(forKey: "selectedVerbLevel") ?? "basic" - // 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) + 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)" + ) } } @@ -135,7 +129,7 @@ struct WordOfDayWidgetView: View { .lineLimit(2) .multilineTextAlignment(.center) - Text("Week \(word.weekNumber)") + Text(word.subtitle) .font(.caption2) .foregroundStyle(.orange) } @@ -168,9 +162,9 @@ struct WordOfDayWidgetView: View { } HStack { - Image(systemName: "calendar") + Image(systemName: "tag") .font(.caption2) - Text("Week \(word.weekNumber)") + Text(word.subtitle) .font(.caption2) } .foregroundStyle(.secondary) @@ -184,11 +178,11 @@ struct WordOfDayWidgetView: View { #Preview(as: .systemSmall) { WordOfDayWidget() } 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) { WordOfDayWidget() } 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")) } diff --git a/Conjuga/SharedModels/Sources/SharedModels/VerbStore.swift b/Conjuga/SharedModels/Sources/SharedModels/VerbStore.swift new file mode 100644 index 0000000..5c22f0f --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/VerbStore.swift @@ -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( + predicate: #Predicate { 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 + } +} diff --git a/Conjuga/SharedModels/Sources/SharedModels/WidgetSnapshot.swift b/Conjuga/SharedModels/Sources/SharedModels/WidgetSnapshot.swift index ade7493..f047397 100644 --- a/Conjuga/SharedModels/Sources/SharedModels/WidgetSnapshot.swift +++ b/Conjuga/SharedModels/Sources/SharedModels/WidgetSnapshot.swift @@ -3,12 +3,12 @@ import Foundation public struct WordOfDay: Codable, Equatable, Sendable { public var spanish: 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.english = english - self.weekNumber = weekNumber + self.subtitle = subtitle } } @@ -50,7 +50,7 @@ public struct WidgetData: Codable, Equatable, Sendable { dailyGoal: 50, currentStreak: 3, dueCardCount: 8, - wordOfTheDay: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1), + wordOfTheDay: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"), latestTestScore: 85, latestTestWeek: 2, currentWeek: 2,