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>
341 lines
12 KiB
Swift
341 lines
12 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
import SwiftData
|
|
|
|
struct PracticeView: View {
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
|
@State private var viewModel = PracticeViewModel()
|
|
@State private var speechService = SpeechService()
|
|
@State private var isPracticing = false
|
|
@State private var userProgress: UserProgress?
|
|
|
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if isPracticing {
|
|
practiceSessionView
|
|
} else {
|
|
practiceHomeView
|
|
}
|
|
}
|
|
.navigationTitle("Practice")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onAppear(perform: loadProgress)
|
|
.onChange(of: isPracticing) { _, practicing in
|
|
if !practicing {
|
|
loadProgress()
|
|
}
|
|
}
|
|
.toolbar {
|
|
if isPracticing {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Done") {
|
|
withAnimation {
|
|
isPracticing = false
|
|
}
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .primaryAction) {
|
|
sessionStatsLabel
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Home View
|
|
|
|
private var practiceHomeView: some View {
|
|
ScrollView {
|
|
VStack(spacing: 28) {
|
|
// Daily progress
|
|
if let progress = userProgress {
|
|
VStack(spacing: 8) {
|
|
DailyProgressRing(
|
|
current: progress.todayCount,
|
|
goal: progress.dailyGoal
|
|
)
|
|
.frame(width: 160, height: 160)
|
|
|
|
Text("\(progress.todayCount) / \(progress.dailyGoal)")
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
if progress.currentStreak > 0 {
|
|
Label("\(progress.currentStreak) day streak", systemImage: "flame.fill")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
// Mode selection
|
|
VStack(spacing: 12) {
|
|
Text("Choose a Mode")
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
ForEach(PracticeMode.allCases) { mode in
|
|
ModeButton(mode: mode) {
|
|
viewModel.practiceMode = mode
|
|
viewModel.focusMode = .none
|
|
viewModel.sessionCorrect = 0
|
|
viewModel.sessionTotal = 0
|
|
viewModel.loadNextCard(
|
|
localContext: modelContext,
|
|
cloudContext: cloudModelContext
|
|
)
|
|
withAnimation {
|
|
isPracticing = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
// Quick Actions
|
|
VStack(spacing: 12) {
|
|
Text("Quick Actions")
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// Weak verbs focus
|
|
Button {
|
|
viewModel.practiceMode = .flashcard
|
|
viewModel.focusMode = .weakVerbs
|
|
viewModel.sessionCorrect = 0
|
|
viewModel.sessionTotal = 0
|
|
viewModel.loadNextCard(
|
|
localContext: modelContext,
|
|
cloudContext: cloudModelContext
|
|
)
|
|
withAnimation { isPracticing = true }
|
|
} label: {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.title3)
|
|
.foregroundStyle(.red)
|
|
.frame(width: 32)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Weak Verbs")
|
|
.font(.subheadline.weight(.semibold))
|
|
Text("Focus on verbs you struggle with")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.tint(.primary)
|
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
|
|
|
// Irregularity drills
|
|
Menu {
|
|
Button("Spelling Changes (c→qu, z→c, ...)") {
|
|
startIrregularityDrill(.spelling)
|
|
}
|
|
Button("Stem Changes (o→ue, e→ie, ...)") {
|
|
startIrregularityDrill(.stemChange)
|
|
}
|
|
Button("Unique Irregulars (ser, ir, ...)") {
|
|
startIrregularityDrill(.uniqueIrregular)
|
|
}
|
|
} label: {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: "wand.and.stars")
|
|
.font(.title3)
|
|
.foregroundStyle(.purple)
|
|
.frame(width: 32)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Irregularity Drills")
|
|
.font(.subheadline.weight(.semibold))
|
|
Text("Practice by irregularity type")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.tint(.primary)
|
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
// Session stats summary
|
|
if viewModel.sessionTotal > 0 && !isPracticing {
|
|
VStack(spacing: 8) {
|
|
Text("Last Session")
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
HStack(spacing: 20) {
|
|
StatItem(label: "Reviewed", value: "\(viewModel.sessionTotal)")
|
|
StatItem(label: "Correct", value: "\(viewModel.sessionCorrect)")
|
|
StatItem(
|
|
label: "Accuracy",
|
|
value: "\(Int(viewModel.sessionAccuracy * 100))%"
|
|
)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
.padding(.vertical)
|
|
.adaptiveContainer()
|
|
}
|
|
}
|
|
|
|
// MARK: - Practice Session View
|
|
|
|
@ViewBuilder
|
|
private var practiceSessionView: some View {
|
|
if !viewModel.hasCards {
|
|
ContentUnavailableView(
|
|
"No Cards Available",
|
|
systemImage: "rectangle.on.rectangle.slash",
|
|
description: Text("Add some verbs to your practice deck to get started.")
|
|
)
|
|
} else {
|
|
switch viewModel.practiceMode {
|
|
case .flashcard:
|
|
FlashcardView(viewModel: viewModel, speechService: speechService)
|
|
case .typing:
|
|
TypingView(viewModel: viewModel, speechService: speechService)
|
|
case .multipleChoice:
|
|
MultipleChoiceView(viewModel: viewModel, speechService: speechService)
|
|
case .fullTable:
|
|
FullTableView(speechService: speechService)
|
|
case .handwriting:
|
|
HandwritingView(viewModel: viewModel, speechService: speechService)
|
|
case .sentenceBuilder:
|
|
SentenceBuilderView()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Session Stats Label
|
|
|
|
private var sessionStatsLabel: some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text("\(viewModel.sessionCorrect)/\(viewModel.sessionTotal)")
|
|
.font(.subheadline.weight(.medium))
|
|
.contentTransition(.numericText())
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Mode Button
|
|
|
|
private struct ModeButton: View {
|
|
let mode: PracticeMode
|
|
let action: () -> Void
|
|
|
|
private var description: String {
|
|
switch mode {
|
|
case .flashcard: "Reveal answers at your own pace"
|
|
case .typing: "Type the conjugation from memory"
|
|
case .multipleChoice: "Pick the correct form from options"
|
|
case .fullTable: "Conjugate all persons for a verb + tense"
|
|
case .handwriting: "Write the answer with Apple Pencil"
|
|
case .sentenceBuilder: "Arrange Spanish words in correct order"
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 16) {
|
|
Image(systemName: mode.icon)
|
|
.font(.title2)
|
|
.frame(width: 40)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(mode.label)
|
|
.font(.headline)
|
|
|
|
Text(description)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.tint(.primary)
|
|
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
}
|
|
|
|
// MARK: - Stat Item
|
|
|
|
private struct StatItem: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(spacing: 4) {
|
|
Text(value)
|
|
.font(.title2.bold().monospacedDigit())
|
|
.contentTransition(.numericText())
|
|
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
extension PracticeView {
|
|
fileprivate func startIrregularityDrill(_ filter: IrregularityFilter) {
|
|
viewModel.practiceMode = .flashcard
|
|
viewModel.focusMode = .irregularity(filter)
|
|
viewModel.sessionCorrect = 0
|
|
viewModel.sessionTotal = 0
|
|
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
|
|
withAnimation { isPracticing = true }
|
|
}
|
|
|
|
private func loadProgress() {
|
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
|
userProgress = progress
|
|
try? cloudModelContext.save()
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
PracticeView()
|
|
.modelContainer(for: [UserProgress.self, ReviewCard.self, Verb.self], inMemory: true)
|
|
}
|