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>
231 lines
8.4 KiB
Swift
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
|
|
)
|
|
}
|