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>
243 lines
9.0 KiB
Swift
243 lines
9.0 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
import SwiftData
|
|
import WidgetKit
|
|
import BackgroundTasks
|
|
|
|
@MainActor
|
|
private enum CloudPreviewContainer {
|
|
static let value: ModelContainer = {
|
|
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
|
return try! ModelContainer(
|
|
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
|
TestResult.self, DailyLog.self,
|
|
configurations: configuration
|
|
)
|
|
}()
|
|
}
|
|
|
|
typealias CloudModelContextProvider = @MainActor @Sendable () -> ModelContext
|
|
private let appRefreshTaskIdentifier = "com.conjuga.app.refresh"
|
|
|
|
private struct CloudModelContextProviderKey: EnvironmentKey {
|
|
static let defaultValue: CloudModelContextProvider = {
|
|
CloudPreviewContainer.value.mainContext
|
|
}
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var cloudModelContextProvider: CloudModelContextProvider {
|
|
get { self[CloudModelContextProviderKey.self] }
|
|
set { self[CloudModelContextProviderKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
@main
|
|
struct ConjugaApp: App {
|
|
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@State private var isReady = false
|
|
@State private var syncMonitor = SyncStatusMonitor()
|
|
|
|
let localContainer: ModelContainer
|
|
let cloudContainer: ModelContainer
|
|
|
|
init() {
|
|
guard let localURL = SharedStore.localStoreURL() else {
|
|
fatalError("App group 'group.com.conjuga.app' is not accessible. Check entitlements and provisioning profile.")
|
|
}
|
|
|
|
// One-time force-reset of the local store to clear stale schema metadata
|
|
// accumulated from previous container configurations.
|
|
Self.performOneTimeLocalStoreResetIfNeeded(at: localURL)
|
|
|
|
// DIAGNOSTIC: what's in the store file BEFORE we open it via SwiftData?
|
|
StoreInspector.dump(at: localURL, label: "before-open")
|
|
|
|
do {
|
|
localContainer = try Self.makeValidatedLocalContainer(at: localURL)
|
|
SharedStore.localContainer = localContainer
|
|
|
|
// DIAGNOSTIC: what's in the store file AFTER SwiftData opened it?
|
|
StoreInspector.dump(at: localURL, label: "after-open")
|
|
print("[ConjugaApp] localContainer identity: \(ObjectIdentifier(localContainer))")
|
|
|
|
let cloudConfig = ModelConfiguration(
|
|
"cloud",
|
|
schema: Schema([
|
|
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
|
TestResult.self, DailyLog.self,
|
|
]),
|
|
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
|
)
|
|
cloudContainer = try ModelContainer(
|
|
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
|
TestResult.self, DailyLog.self,
|
|
configurations: cloudConfig
|
|
)
|
|
} catch {
|
|
fatalError("Failed to create ModelContainer: \(error)")
|
|
}
|
|
}
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
Group {
|
|
if !isReady {
|
|
VStack(spacing: 16) {
|
|
ProgressView()
|
|
.controlSize(.large)
|
|
Text("Preparing data...")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else if onboardingComplete {
|
|
MainTabView()
|
|
} else {
|
|
OnboardingView()
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if syncMonitor.shouldShowToast {
|
|
SyncToast()
|
|
.padding(.bottom, 100)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
}
|
|
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
|
|
.environment(syncMonitor)
|
|
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
|
.task {
|
|
if let url = SharedStore.localStoreURL() {
|
|
StoreInspector.dump(at: url, label: "before-bootstrap")
|
|
}
|
|
await StartupCoordinator.bootstrap(localContainer: localContainer)
|
|
if let url = SharedStore.localStoreURL() {
|
|
StoreInspector.dump(at: url, label: "after-bootstrap")
|
|
}
|
|
isReady = true
|
|
|
|
Task { @MainActor in
|
|
syncMonitor.beginSync()
|
|
await StartupCoordinator.runMaintenance(
|
|
localContainer: localContainer,
|
|
cloudContainer: cloudContainer
|
|
)
|
|
WidgetDataService.update(
|
|
localContainer: localContainer,
|
|
cloudContainer: cloudContainer
|
|
)
|
|
syncMonitor.endSync()
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
if newPhase == .background {
|
|
WidgetDataService.update(
|
|
localContainer: localContainer,
|
|
cloudContainer: cloudContainer
|
|
)
|
|
Self.scheduleAppRefresh()
|
|
}
|
|
}
|
|
}
|
|
.modelContainer(localContainer)
|
|
.backgroundTask(.appRefresh(appRefreshTaskIdentifier)) {
|
|
Self.scheduleAppRefresh()
|
|
await refreshWidgetData()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func refreshWidgetData() async {
|
|
WidgetDataService.update(
|
|
localContainer: localContainer,
|
|
cloudContainer: cloudContainer
|
|
)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
|
|
nonisolated static func scheduleAppRefresh() {
|
|
let request = BGAppRefreshTaskRequest(identifier: appRefreshTaskIdentifier)
|
|
// Minimum delay — system decides actual run time based on usage patterns.
|
|
// We want the widget refreshed before the user typically opens the app.
|
|
request.earliestBeginDate = Calendar.current.startOfDay(
|
|
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
|
)
|
|
|
|
do {
|
|
try BGTaskScheduler.shared.submit(request)
|
|
} catch {
|
|
print("Failed to schedule app refresh: \(error)")
|
|
}
|
|
}
|
|
|
|
private static func makeValidatedLocalContainer(at url: URL) throws -> ModelContainer {
|
|
let container = try makeLocalContainer(at: url)
|
|
if localStoreIsUsable(container: container) {
|
|
return container
|
|
}
|
|
|
|
deleteStoreFiles(at: url)
|
|
UserDefaults.standard.removeObject(forKey: "courseDataVersion")
|
|
print("Reset corrupted local reference store")
|
|
|
|
return try makeLocalContainer(at: url)
|
|
}
|
|
|
|
private static func makeLocalContainer(at url: URL) throws -> ModelContainer {
|
|
let localConfig = ModelConfiguration(
|
|
"local",
|
|
schema: Schema([
|
|
Verb.self, VerbForm.self, IrregularSpan.self,
|
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
|
]),
|
|
url: url,
|
|
cloudKitDatabase: .none
|
|
)
|
|
return try ModelContainer(
|
|
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
|
configurations: localConfig
|
|
)
|
|
}
|
|
|
|
private static func localStoreIsUsable(container: ModelContainer) -> Bool {
|
|
let context = ModelContext(container)
|
|
do {
|
|
_ = try context.fetchCount(FetchDescriptor<Verb>())
|
|
return true
|
|
} catch {
|
|
print("Local reference store validation failed: \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func deleteStoreFiles(at url: URL) {
|
|
let fileManager = FileManager.default
|
|
for suffix in ["", "-wal", "-shm"] {
|
|
let candidateURL = URL(fileURLWithPath: url.path + suffix)
|
|
guard fileManager.fileExists(atPath: candidateURL.path) else { continue }
|
|
try? fileManager.removeItem(at: candidateURL)
|
|
}
|
|
}
|
|
|
|
/// One-time nuclear reset of the local reference store.
|
|
/// Clears accumulated stale schema metadata from previous container configurations.
|
|
/// Bump the version number to force another reset if the schema changes again.
|
|
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
|
|
let resetVersion = 2 // bump: widget schema moved to SharedModels
|
|
let key = "localStoreResetVersion"
|
|
let defaults = UserDefaults.standard
|
|
|
|
guard defaults.integer(forKey: key) < resetVersion else { return }
|
|
|
|
print("[ConjugaApp] Performing one-time local store reset (v\(resetVersion))")
|
|
deleteStoreFiles(at: url)
|
|
|
|
// Clear any version flags that gate seeding so everything re-seeds cleanly.
|
|
defaults.removeObject(forKey: "courseDataVersion")
|
|
defaults.removeObject(forKey: "courseProgressMigrationVersion")
|
|
|
|
defaults.set(resetVersion, forKey: key)
|
|
}
|
|
}
|