Files
Spanish/Conjuga/ConjugaWidget/CombinedWidget.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

231 lines
8.4 KiB
Swift

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