Files
Spanish/Conjuga/ConjugaWidget/WordOfDayWidget.swift
Trey t 4b467ec136 Initial commit: Conjuga Spanish conjugation app
Includes SwiftData dual-store architecture (local reference + CloudKit user data),
JSON-based data seeding, 20 tense guides, 20 grammar notes, SRS review system,
course vocabulary, and widget support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:58:33 -05:00

202 lines
6.9 KiB
Swift

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<WordOfDayEntry>) -> 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<CourseDeck>(
predicate: #Predicate<CourseDeck> { $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))
}