Move reference-data models to SharedModels to fix widget-triggered data loss

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>
This commit is contained in:
Trey t
2026-04-10 13:51:02 -05:00
parent 4f30200544
commit fd5861c48d
48 changed files with 969 additions and 306 deletions

View File

@@ -2,9 +2,6 @@ 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
@@ -24,13 +21,13 @@ struct CombinedProvider: TimelineProvider {
completion(CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder))
return
}
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
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()) ?? Self.previewWord
let word = fetchWordOfDay(for: Date())
let data = WidgetDataReader.read()
let entry = CombinedEntry(date: Date(), word: word, data: data)
@@ -42,23 +39,24 @@ struct CombinedProvider: TimelineProvider {
}
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 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: VocabCard.self, CourseDeck.self,
configurations: ModelConfiguration(
"local",
url: localURL,
cloudKitDatabase: .none
)
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
configurations: config
) else { return nil }
let context = ModelContext(container)
@@ -71,9 +69,20 @@ struct CombinedProvider: TimelineProvider {
let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId }
)
let week = (try? context.fetch(deckDescriptor))?.first?.weekNumber ?? 1
let deck = (try? context.fetch(deckDescriptor))?.first
let week = deck?.weekNumber ?? 1
return WordOfDay(spanish: card.front, english: card.back, weekNumber: week)
// 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)
}
}

View File

@@ -2,9 +2,6 @@ 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
@@ -16,21 +13,16 @@ struct WordOfDayProvider: TimelineProvider {
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))
completion(WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)))
return
}
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
completion(WordOfDayEntry(date: Date(), word: word))
completion(WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date())))
}
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 entry = WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date()))
let tomorrow = Calendar.current.startOfDay(
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
)
@@ -38,48 +30,49 @@ struct WordOfDayProvider: TimelineProvider {
}
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))")
guard let localURL = SharedStore.localStoreURL() else { return nil }
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: ", "))")
// 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 descriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId }
)
let deck = (try? context.fetch(descriptor))?.first
let week = deck?.weekNumber ?? 1
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
// 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)
}
}