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>
233 lines
8.2 KiB
Swift
233 lines
8.2 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
import SwiftData
|
|
import SharedModels
|
|
|
|
struct CombinedEntry: TimelineEntry {
|
|
let date: Date
|
|
let word: WordOfDay?
|
|
let data: WidgetData
|
|
}
|
|
|
|
struct CombinedProvider: TimelineProvider {
|
|
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)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (CombinedEntry) -> Void) {
|
|
if context.isPreview {
|
|
completion(CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder))
|
|
return
|
|
}
|
|
let word = fetchWordOfDay(for: Date())
|
|
let data = WidgetDataReader.read()
|
|
completion(CombinedEntry(date: Date(), word: word, data: data))
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<CombinedEntry>) -> Void) {
|
|
let word = fetchWordOfDay(for: Date())
|
|
let data = WidgetDataReader.read()
|
|
let entry = CombinedEntry(date: Date(), word: word, data: data)
|
|
|
|
// Expire at midnight for new word
|
|
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 CombinedWidget: Widget {
|
|
let kind = "CombinedWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: kind, provider: CombinedProvider()) { entry in
|
|
CombinedWidgetView(entry: entry)
|
|
.containerBackground(.fill.tertiary, for: .widget)
|
|
}
|
|
.configurationDisplayName("Conjuga Overview")
|
|
.description("Word of the day with daily stats and progress.")
|
|
.supportedFamilies([.systemLarge])
|
|
}
|
|
}
|
|
|
|
struct CombinedWidgetView: View {
|
|
let entry: CombinedEntry
|
|
|
|
var body: some View {
|
|
VStack(spacing: 16) {
|
|
// Word of the Day section
|
|
if let word = entry.word {
|
|
VStack(spacing: 6) {
|
|
Text("WORD OF THE DAY")
|
|
.font(.caption2.weight(.bold))
|
|
.foregroundStyle(.orange)
|
|
.tracking(1.5)
|
|
|
|
Text(word.spanish)
|
|
.font(.largeTitle.bold())
|
|
.minimumScaleFactor(0.5)
|
|
.lineLimit(1)
|
|
|
|
Text(word.english)
|
|
.font(.title3)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 12) {
|
|
Text(word.subtitle)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
|
|
Button(intent: NewWordIntent()) {
|
|
Label("New Word", systemImage: "arrow.triangle.2.circlepath")
|
|
.font(.caption2)
|
|
}
|
|
.tint(.orange)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Stats grid
|
|
HStack(spacing: 0) {
|
|
// Daily progress
|
|
VStack(spacing: 6) {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(.quaternary, lineWidth: 5)
|
|
Circle()
|
|
.trim(from: 0, to: entry.data.progressPercent)
|
|
.stroke(.orange, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
VStack(spacing: 0) {
|
|
Text("\(entry.data.todayCount)")
|
|
.font(.title3.bold().monospacedDigit())
|
|
Text("/\(entry.data.dailyGoal)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(width: 60, height: 60)
|
|
|
|
Text("Today")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
// Streak
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "flame.fill")
|
|
.font(.title)
|
|
.foregroundStyle(.orange)
|
|
|
|
Text("\(entry.data.currentStreak)")
|
|
.font(.title3.bold().monospacedDigit())
|
|
|
|
Text("Streak")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
// Due cards
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "clock.badge.exclamationmark")
|
|
.font(.title)
|
|
.foregroundStyle(entry.data.dueCardCount > 0 ? .blue : .secondary)
|
|
|
|
Text("\(entry.data.dueCardCount)")
|
|
.font(.title3.bold().monospacedDigit())
|
|
|
|
Text("Due")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
// Test score
|
|
VStack(spacing: 6) {
|
|
Image(systemName: entry.data.latestTestScore ?? 0 >= 90 ? "star.fill" : "pencil.and.list.clipboard")
|
|
.font(.title)
|
|
.foregroundStyle(scoreColor)
|
|
|
|
if let score = entry.data.latestTestScore {
|
|
Text("\(score)%")
|
|
.font(.title3.bold().monospacedDigit())
|
|
} else {
|
|
Text("—")
|
|
.font(.title3.bold())
|
|
}
|
|
|
|
Text("Test")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
private var scoreColor: Color {
|
|
guard let score = entry.data.latestTestScore else { return .secondary }
|
|
if score >= 90 { return .yellow }
|
|
if score >= 70 { return .green }
|
|
return .orange
|
|
}
|
|
}
|
|
|
|
#Preview(as: .systemLarge) {
|
|
CombinedWidget()
|
|
} timeline: {
|
|
CombinedEntry(
|
|
date: Date(),
|
|
word: WordOfDay(spanish: "hablar", english: "to speak", subtitle: "Level: Basic"),
|
|
data: .placeholder
|
|
)
|
|
}
|