import WidgetKit import SwiftUI import SwiftData import SharedModels import os private let logger = Logger(subsystem: "com.conjuga.app.widget", category: "CombinedWidget") 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", weekNumber: 1) 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()) ?? Self.previewWord 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()) ?? Self.previewWord 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? { let localURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.conjuga.app")!.appendingPathComponent("local.store") logger.info("Combined store path: \(localURL.path), exists: \(FileManager.default.fileExists(atPath: localURL.path))") if !FileManager.default.fileExists(atPath: localURL.path) { let dir = localURL.deletingLastPathComponent() let contents = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? [] logger.error("local.store NOT FOUND. Contents: \(contents.joined(separator: ", "))") return nil } guard let container = try? ModelContainer( for: VocabCard.self, CourseDeck.self, configurations: ModelConfiguration( "local", url: localURL, cloudKitDatabase: .none ) ) 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 deckDescriptor = FetchDescriptor( predicate: #Predicate { $0.id == deckId } ) let week = (try? context.fetch(deckDescriptor))?.first?.weekNumber ?? 1 return WordOfDay(spanish: card.front, english: card.back, weekNumber: week) } } 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("Week \(word.weekNumber)") .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", weekNumber: 1), data: .placeholder ) }