import WidgetKit import SwiftUI import SwiftData import SharedModels import os private let logger = Logger(subsystem: "com.conjuga.app.widget", category: "WordOfDay") struct WordOfDayEntry: TimelineEntry { let date: Date let word: WordOfDay? } struct WordOfDayProvider: TimelineProvider { func placeholder(in context: Context) -> WordOfDayEntry { WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)) } private static let previewWord = WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1) func getSnapshot(in context: Context, completion: @escaping (WordOfDayEntry) -> Void) { if context.isPreview { completion(WordOfDayEntry(date: Date(), word: Self.previewWord)) return } let word = fetchWordOfDay(for: Date()) ?? Self.previewWord completion(WordOfDayEntry(date: Date(), word: word)) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let word = fetchWordOfDay(for: Date()) ?? Self.previewWord let entry = WordOfDayEntry(date: Date(), word: 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("Store path: \(localURL.path)") logger.info("Store 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. App Support contents: \(contents.joined(separator: ", "))") return nil } do { let container = try ModelContainer( for: VocabCard.self, CourseDeck.self, configurations: ModelConfiguration( "local", url: localURL, cloudKitDatabase: .none ) ) logger.info("ModelContainer opened OK") 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 { logger.error("Store has 0 VocabCards") return nil } logger.info("Picked card: \(card.front) = \(card.back)") 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) } catch { logger.error("Failed: \(error.localizedDescription)") return nil } } } struct WordOfDayWidget: Widget { let kind = "WordOfDayWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: WordOfDayProvider()) { entry in WordOfDayWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Word of the Day") .description("Learn a new Spanish word every day.") .supportedFamilies([.systemSmall, .systemMedium]) } } struct WordOfDayWidgetView: View { @Environment(\.widgetFamily) var family let entry: WordOfDayEntry var body: some View { if let word = entry.word { switch family { case .systemSmall: smallView(word: word) case .systemMedium: mediumView(word: word) default: smallView(word: word) } } else { VStack { Image(systemName: "textformat.abc") .font(.title) .foregroundStyle(.secondary) Text("Open Conjuga to start") .font(.caption) .foregroundStyle(.secondary) } } } private func smallView(word: WordOfDay) -> some View { VStack(spacing: 8) { Text("Word of the Day") .font(.caption2) .foregroundStyle(.secondary) .textCase(.uppercase) Text(word.spanish) .font(.title2.bold()) .minimumScaleFactor(0.6) .lineLimit(1) Text(word.english) .font(.subheadline) .foregroundStyle(.secondary) .minimumScaleFactor(0.6) .lineLimit(2) .multilineTextAlignment(.center) Text("Week \(word.weekNumber)") .font(.caption2) .foregroundStyle(.orange) } } private func mediumView(word: WordOfDay) -> some View { HStack(spacing: 16) { VStack(spacing: 4) { Text("WORD OF THE DAY") .font(.caption2.weight(.semibold)) .foregroundStyle(.orange) Text(word.spanish) .font(.largeTitle.bold()) .minimumScaleFactor(0.5) .lineLimit(1) } .frame(maxWidth: .infinity) Divider() VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 2) { Text("English") .font(.caption2) .foregroundStyle(.secondary) Text(word.english) .font(.headline) .lineLimit(2) } HStack { Image(systemName: "calendar") .font(.caption2) Text("Week \(word.weekNumber)") .font(.caption2) } .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 4) } } #Preview(as: .systemSmall) { WordOfDayWidget() } timeline: { WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)) } #Preview(as: .systemMedium) { WordOfDayWidget() } timeline: { WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)) }