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,