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:
@@ -0,0 +1,40 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
@Model
|
||||
public final class IrregularSpan {
|
||||
public var verbId: Int = 0
|
||||
public var tenseId: String = ""
|
||||
public var personIndex: Int = 0
|
||||
public var spanType: Int = 0
|
||||
public var pattern: Int = 0
|
||||
public var start: Int = 0
|
||||
public var end: Int = 0
|
||||
|
||||
public var verbForm: VerbForm?
|
||||
|
||||
public init(verbId: Int, tenseId: String, personIndex: Int, spanType: Int, pattern: Int, start: Int, end: Int) {
|
||||
self.verbId = verbId
|
||||
self.tenseId = tenseId
|
||||
self.personIndex = personIndex
|
||||
self.spanType = spanType
|
||||
self.pattern = pattern
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
|
||||
public var category: SpanCategory {
|
||||
switch spanType {
|
||||
case 100..<200: return .spelling
|
||||
case 200..<300: return .stemChange
|
||||
case 300..<400: return .uniqueIrregular
|
||||
default: return .uniqueIrregular
|
||||
}
|
||||
}
|
||||
|
||||
public enum SpanCategory: String, Sendable {
|
||||
case spelling = "Spelling Change"
|
||||
case stemChange = "Stem Change"
|
||||
case uniqueIrregular = "Unique Irregular"
|
||||
}
|
||||
}
|
||||
26
Conjuga/SharedModels/Sources/SharedModels/SharedStore.swift
Normal file
26
Conjuga/SharedModels/Sources/SharedModels/SharedStore.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
public enum SharedStore {
|
||||
public static let appGroupID = "group.com.conjuga.app"
|
||||
|
||||
/// Resolves the local SwiftData store URL inside the shared app group container
|
||||
/// at the canonical `Library/Application Support/local.store` path.
|
||||
/// Returns nil if the app group isn't accessible (entitlement / profile issue).
|
||||
public static func localStoreURL() -> URL? {
|
||||
guard let groupURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: appGroupID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let dir = groupURL.appendingPathComponent("Library/Application Support")
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("local.store")
|
||||
}
|
||||
|
||||
/// Global reference to the main app's local reference-data container.
|
||||
/// Set by `ConjugaApp.init()` so any view can bypass `@Environment(\.modelContext)`
|
||||
/// and hit the exact container used for seeding.
|
||||
@MainActor
|
||||
public static var localContainer: ModelContainer?
|
||||
}
|
||||
15
Conjuga/SharedModels/Sources/SharedModels/TenseGuide.swift
Normal file
15
Conjuga/SharedModels/Sources/SharedModels/TenseGuide.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
@Model
|
||||
public final class TenseGuide {
|
||||
public var tenseId: String = ""
|
||||
public var title: String = ""
|
||||
public var body: String = ""
|
||||
|
||||
public init(tenseId: String, title: String, body: String) {
|
||||
self.tenseId = tenseId
|
||||
self.title = title
|
||||
self.body = body
|
||||
}
|
||||
}
|
||||
65
Conjuga/SharedModels/Sources/SharedModels/Verb.swift
Normal file
65
Conjuga/SharedModels/Sources/SharedModels/Verb.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
public enum VerbLevel: String, CaseIterable, Sendable {
|
||||
case basic
|
||||
case elementary
|
||||
case intermediate
|
||||
case advanced
|
||||
case expert
|
||||
|
||||
public var displayName: String { rawValue.capitalized }
|
||||
}
|
||||
|
||||
@Model
|
||||
public final class Verb {
|
||||
public var id: Int = 0
|
||||
public var infinitive: String = ""
|
||||
public var english: String = ""
|
||||
public var rank: Int = 0
|
||||
public var ending: String = ""
|
||||
public var reflexive: Int = 0
|
||||
public var level: String = ""
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \VerbForm.verb)
|
||||
public var forms: [VerbForm]?
|
||||
|
||||
public init(id: Int, infinitive: String, english: String, rank: Int, ending: String, reflexive: Int, level: String) {
|
||||
self.id = id
|
||||
self.infinitive = infinitive
|
||||
self.english = english
|
||||
self.rank = rank
|
||||
self.ending = ending
|
||||
self.reflexive = reflexive
|
||||
self.level = level
|
||||
}
|
||||
}
|
||||
|
||||
public enum VerbLevelGroup: String, CaseIterable, Sendable {
|
||||
case basic = "basic"
|
||||
case elementary = "elementary"
|
||||
case intermediate = "intermediate"
|
||||
case advanced = "advanced"
|
||||
case expert = "expert"
|
||||
|
||||
public static func dataLevels(for selectedLevel: String) -> Set<String> {
|
||||
switch selectedLevel {
|
||||
case Self.basic.rawValue:
|
||||
return ["basic"]
|
||||
case Self.elementary.rawValue:
|
||||
return ["elementary", "elementary_1", "elementary_2", "elementary_3"]
|
||||
case Self.intermediate.rawValue:
|
||||
return ["intermediate", "intermediate_1", "intermediate_2", "intermediate_3", "intermediate_4"]
|
||||
case Self.advanced.rawValue:
|
||||
return ["advanced"]
|
||||
case Self.expert.rawValue:
|
||||
return ["expert"]
|
||||
default:
|
||||
return [selectedLevel]
|
||||
}
|
||||
}
|
||||
|
||||
public static func matches(_ dataLevel: String, selectedLevel: String) -> Bool {
|
||||
dataLevels(for: selectedLevel).contains(dataLevel)
|
||||
}
|
||||
}
|
||||
24
Conjuga/SharedModels/Sources/SharedModels/VerbForm.swift
Normal file
24
Conjuga/SharedModels/Sources/SharedModels/VerbForm.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
@Model
|
||||
public final class VerbForm {
|
||||
public var verbId: Int = 0
|
||||
public var tenseId: String = ""
|
||||
public var personIndex: Int = 0
|
||||
public var form: String = ""
|
||||
public var regularity: String = ""
|
||||
|
||||
public var verb: Verb?
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \IrregularSpan.verbForm)
|
||||
public var spans: [IrregularSpan]?
|
||||
|
||||
public init(verbId: Int, tenseId: String, personIndex: Int, form: String, regularity: String) {
|
||||
self.verbId = verbId
|
||||
self.tenseId = tenseId
|
||||
self.personIndex = personIndex
|
||||
self.form = form
|
||||
self.regularity = regularity
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user