Root cause: the widget was opening the shared local.store with a 2-entity schema (VocabCard, CourseDeck), causing SwiftData to destructively migrate the file and drop the 4 entities the widget didn't know about (Verb, VerbForm, IrregularSpan, TenseGuide). The main app would then re-seed on next launch, and the cycle repeated forever. Fix: move Verb, VerbForm, IrregularSpan, TenseGuide from the app target into SharedModels so both the main app and the widget use the exact same types from the same module. Both now declare all 6 local entities in their ModelContainer, producing identical schema hashes and eliminating the destructive migration. Other changes bundled in this commit (accumulated during debugging): - Split ModelContainer into localContainer + cloudContainer (no more CloudKit + non-CloudKit configs in one container) - Add SharedStore.localStoreURL() helper and a global reference for bypass-environment fetches - One-time store reset mechanism to wipe stale schema metadata from previous broken iterations - Bootstrap/maintenance split so only seeding gates the UI; dedup and cloud repair run in the background - Sync status toast that shows "Syncing" while background maintenance runs (network-aware, auto-dismisses) - Background app refresh task to keep the widget word-of-day fresh - Speaker icon on VerbDetailView for TTS - Grammar notes navigation fix (nested NavigationStack was breaking detail pane on iPhone) - Word-of-day widget swaps front/back when the deck is reversed so the Spanish word always shows in bold - StoreInspector diagnostic helper for raw SQLite table inspection - Add Conjuga scheme explicitly to project.yml so xcodegen doesn't drop it Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
240 lines
8.5 KiB
Swift
240 lines
8.5 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
import SwiftData
|
|
import SharedModels
|
|
|
|
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())
|
|
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())
|
|
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? {
|
|
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
|
|
|
// MUST declare all 6 local entities to match the main app's schema.
|
|
// Declaring a subset would cause SwiftData to destructively migrate the store
|
|
// on open, dropping the entities not listed here.
|
|
let config = ModelConfiguration(
|
|
"local",
|
|
schema: Schema([
|
|
Verb.self, VerbForm.self, IrregularSpan.self,
|
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
|
]),
|
|
url: localURL,
|
|
cloudKitDatabase: .none
|
|
)
|
|
guard let container = try? ModelContainer(
|
|
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
|
configurations: config
|
|
) 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 deck = (try? context.fetch(deckDescriptor))?.first
|
|
let week = deck?.weekNumber ?? 1
|
|
|
|
// If the deck is reversed (English on front), swap so spanish is always Spanish.
|
|
let spanish: String
|
|
let english: String
|
|
if deck?.isReversed == true {
|
|
spanish = card.back
|
|
english = card.front
|
|
} else {
|
|
spanish = card.front
|
|
english = card.back
|
|
}
|
|
return WordOfDay(spanish: spanish, english: english, 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
|
|
)
|
|
}
|