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) -> 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 ) }