Make Full Table level-agnostic, fix the streak system end-to-end
Full Table (issue from chat): drop the level filter — Full Table tests regular conjugation patterns, not vocabulary recognition, so restricting to Basic-level verbs collapsed the eligible pool to two combos (vivir present, ir future). Pool now draws from all 1,750 verbs. Random sampling first; if 40 attempts fail we fall through to a deterministic shuffled scan that guarantees finding any eligible (verb, tense) combo when one exists. Returning nil now happens only when the user's filters genuinely produce zero eligible prompts. The view replaces its silent blank screen with a ContentUnavailableView pointing at the settings that need adjusting. FeatureReferenceView documents the level exception. Streak (issue #31 follow-up): activity recording was scoped to flashcard and Full Table reviews only, so spending an hour on textbook work, guides, videos, or AI chat could break a "streak" that the dashboard kept displaying as if it were intact. Three fixes: 1. Extract ReviewStore.recordActivity(context:) — a streak-only entry point that any user-initiated learning action can call. 2. Add UserProgress.validateStreakIfStale(today:context:) — resets a broken currentStreak to 0 immediately, called from app launch and dashboard appear so the displayed number is never a lie. 3. DailyLog formatter pins POSIX locale + current timezone so the yyyy-MM-dd strings can't drift across locales. Wired recordActivity into every previously-silent learning action: chat send, story-quiz completion, textbook exercise submit, grammar exercise completion, course-deck study finish, week test / checkpoint save, listening + pronunciation check, cloze quiz completion, lyrics word lookup, video stream / play / download success, sentence-builder check, and course-vocab SRS rate (which was bypassing ReviewStore entirely). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -141,6 +141,11 @@ struct ConjugaApp: App {
|
|||||||
localContainer: localContainer,
|
localContainer: localContainer,
|
||||||
cloudContainer: cloudContainer
|
cloudContainer: cloudContainer
|
||||||
)
|
)
|
||||||
|
// Reset a broken streak immediately on launch so the
|
||||||
|
// dashboard never shows a stale number even if the user
|
||||||
|
// hasn't navigated to it yet.
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContainer.mainContext)
|
||||||
|
progress.validateStreakIfStale(context: cloudContainer.mainContext)
|
||||||
WidgetDataService.update(
|
WidgetDataService.update(
|
||||||
localContainer: localContainer,
|
localContainer: localContainer,
|
||||||
cloudContainer: cloudContainer
|
cloudContainer: cloudContainer
|
||||||
|
|||||||
@@ -32,10 +32,21 @@ final class DailyLog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func dateString(from date: Date) -> String {
|
/// Defensive formatter: explicit POSIX locale + current timezone so date
|
||||||
|
/// strings can never drift due to locale formatting (e.g. Arabic numerals)
|
||||||
|
/// or implicit-zone shifts. The string format is timezone-naive
|
||||||
|
/// `yyyy-MM-dd`, which works because we only ever compare to other
|
||||||
|
/// strings produced by this same formatter.
|
||||||
|
private static func makeFormatter() -> DateFormatter {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = .current
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
return formatter.string(from: date)
|
return formatter
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dateString(from date: Date) -> String {
|
||||||
|
makeFormatter().string(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func todayString() -> String {
|
static func todayString() -> String {
|
||||||
@@ -43,8 +54,6 @@ final class DailyLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func date(from string: String) -> Date? {
|
static func date(from string: String) -> Date? {
|
||||||
let formatter = DateFormatter()
|
makeFormatter().date(from: string)
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
return formatter.date(from: string)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,24 @@ final class UserProgress {
|
|||||||
unlockedBadgeIDs = values.sorted()
|
unlockedBadgeIDs = values.sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets `currentStreak` to zero if more than one day has passed since
|
||||||
|
/// the last recorded activity. Without this check the dashboard keeps
|
||||||
|
/// displaying a stale streak number for days after the user actually
|
||||||
|
/// stops practicing — the underlying counter only updates on the *next*
|
||||||
|
/// practice action. Call from app launch and the dashboard's `.task`.
|
||||||
|
@MainActor
|
||||||
|
func validateStreakIfStale(today: Date = Date(), context: ModelContext) {
|
||||||
|
guard !todayDate.isEmpty else { return }
|
||||||
|
let todayString = DailyLog.dateString(from: today)
|
||||||
|
if todayDate == todayString { return }
|
||||||
|
guard let prevDate = DailyLog.date(from: todayDate) else { return }
|
||||||
|
let diff = Calendar.current.dateComponents([.day], from: prevDate, to: today)
|
||||||
|
if (diff.day ?? Int.max) > 1 && currentStreak != 0 {
|
||||||
|
currentStreak = 0
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func migrateLegacyStorageIfNeeded() {
|
func migrateLegacyStorageIfNeeded() {
|
||||||
if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty {
|
if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty {
|
||||||
enabledTenseIDs = enabledTenses
|
enabledTenseIDs = enabledTenses
|
||||||
|
|||||||
@@ -97,32 +97,62 @@ struct PracticeSessionService {
|
|||||||
|
|
||||||
func randomFullTablePrompt() -> FullTablePrompt? {
|
func randomFullTablePrompt() -> FullTablePrompt? {
|
||||||
let settings = settings()
|
let settings = settings()
|
||||||
// Full Table practice is regular-only, so the irregular-category setting is
|
// Full Table is testing the user's grasp of regular conjugation patterns,
|
||||||
// deliberately ignored here (applying it would empty the pool).
|
// not vocabulary recognition. Level filter is intentionally bypassed so
|
||||||
|
// we draw from the entire verb pool — being able to conjugate `hablar`
|
||||||
|
// regularly transfers to any other regular verb regardless of "level".
|
||||||
|
// Irregular-category and tense filters still apply via downstream checks.
|
||||||
let verbs = applyReflexiveFilter(
|
let verbs = applyReflexiveFilter(
|
||||||
to: referenceStore.fetchVerbs(selectedLevels: settings.selectedLevels),
|
to: referenceStore.fetchVerbs(),
|
||||||
settings: settings
|
settings: settings
|
||||||
)
|
)
|
||||||
guard !verbs.isEmpty else { return nil }
|
guard !verbs.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let candidateTenseIds = settings.selectionTenseIDs
|
||||||
|
guard !candidateTenseIds.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// Cheap path: random sampling. With ~1750 verbs and several hundred
|
||||||
|
// fully-regular combos this almost always succeeds within a handful
|
||||||
|
// of attempts.
|
||||||
for _ in 0..<40 {
|
for _ in 0..<40 {
|
||||||
guard let verb = verbs.randomElement(),
|
guard let verb = verbs.randomElement(),
|
||||||
let tenseId = settings.selectionTenseIDs.randomElement(),
|
let tenseId = candidateTenseIds.randomElement(),
|
||||||
let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||||
|
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
// Guarantee: if any eligible (verb, tense) combo exists in the data we
|
||||||
if forms.isEmpty { continue }
|
// return one. Only return nil when the user's settings genuinely produce
|
||||||
|
// an empty pool (so the UI can show an error state instead of a blank).
|
||||||
// Full Table practice is for regular patterns only — skip combos
|
let shuffledVerbs = verbs.shuffled()
|
||||||
// where any form in this (verb, tense) is irregular.
|
let shuffledTenseIds = candidateTenseIds.shuffled()
|
||||||
if forms.contains(where: { $0.regularity != "regular" }) { continue }
|
for verb in shuffledVerbs {
|
||||||
|
for tenseId in shuffledTenseIds {
|
||||||
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms)
|
guard let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||||
|
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a `FullTablePrompt` if this verb's forms in the given tense are
|
||||||
|
/// all marked `regular` and complete. Nil otherwise.
|
||||||
|
private func makePromptIfFullyRegular(
|
||||||
|
verb: Verb,
|
||||||
|
tenseId: String,
|
||||||
|
tenseInfo: TenseInfo
|
||||||
|
) -> FullTablePrompt? {
|
||||||
|
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
||||||
|
guard !forms.isEmpty else { return nil }
|
||||||
|
if forms.contains(where: { $0.regularity != "regular" }) { return nil }
|
||||||
|
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms)
|
||||||
|
}
|
||||||
|
|
||||||
func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
|
func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
|
||||||
ReviewStore.recordReview(
|
ReviewStore.recordReview(
|
||||||
verbId: verbId,
|
verbId: verbId,
|
||||||
|
|||||||
@@ -72,13 +72,13 @@ struct ReviewStore {
|
|||||||
return newCard
|
return newCard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bumps the streak / "showed up today" bookkeeping without touching
|
||||||
|
/// review-specific counters. Call from any user-initiated learning action
|
||||||
|
/// — sending a chat message, doing an exercise, watching a curated video,
|
||||||
|
/// looking up a word in lyrics, etc. Safe to call multiple times per day;
|
||||||
|
/// only the first call on a fresh date moves the streak.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func updateProgress(
|
static func recordActivity(context: ModelContext, date: Date = Date()) -> UserProgress {
|
||||||
reviewIncrement: Int,
|
|
||||||
correctIncrement: Int,
|
|
||||||
context: ModelContext,
|
|
||||||
date: Date = Date()
|
|
||||||
) -> UserProgress {
|
|
||||||
let progress = fetchOrCreateUserProgress(context: context)
|
let progress = fetchOrCreateUserProgress(context: context)
|
||||||
let todayString = DailyLog.dateString(from: date)
|
let todayString = DailyLog.dateString(from: date)
|
||||||
|
|
||||||
@@ -97,9 +97,25 @@ struct ReviewStore {
|
|||||||
progress.todayCount = 0
|
progress.todayCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
|
||||||
|
try? context.save()
|
||||||
|
return progress
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func updateProgress(
|
||||||
|
reviewIncrement: Int,
|
||||||
|
correctIncrement: Int,
|
||||||
|
context: ModelContext,
|
||||||
|
date: Date = Date()
|
||||||
|
) -> UserProgress {
|
||||||
|
// Bump streak / today-date first so review-specific counters land on
|
||||||
|
// the correct day if this is the user's first action after midnight.
|
||||||
|
let progress = recordActivity(context: context, date: date)
|
||||||
|
let todayString = DailyLog.dateString(from: date)
|
||||||
|
|
||||||
progress.todayCount += reviewIncrement
|
progress.todayCount += reviewIncrement
|
||||||
progress.totalReviewed += reviewIncrement
|
progress.totalReviewed += reviewIncrement
|
||||||
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
|
|
||||||
|
|
||||||
let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
|
let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
|
||||||
log.reviewCount += reviewIncrement
|
log.reviewCount += reviewIncrement
|
||||||
|
|||||||
@@ -585,6 +585,7 @@ struct CourseQuizView: View {
|
|||||||
)
|
)
|
||||||
cloudModelContext.insert(result)
|
cloudModelContext.insert(result)
|
||||||
try? cloudModelContext.save()
|
try? cloudModelContext.save()
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import SwiftData
|
|||||||
struct DeckStudyView: View {
|
struct DeckStudyView: View {
|
||||||
let deck: CourseDeck
|
let deck: CourseDeck
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
@State private var isStudying = false
|
@State private var isStudying = false
|
||||||
@State private var speechService = SpeechService()
|
@State private var speechService = SpeechService()
|
||||||
@State private var deckCards: [VocabCard] = []
|
@State private var deckCards: [VocabCard] = []
|
||||||
@@ -24,7 +26,10 @@ struct DeckStudyView: View {
|
|||||||
VocabFlashcardView(
|
VocabFlashcardView(
|
||||||
cards: deckCards.shuffled(),
|
cards: deckCards.shuffled(),
|
||||||
speechService: speechService,
|
speechService: speechService,
|
||||||
onDone: { isStudying = false },
|
onDone: {
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
|
isStudying = false
|
||||||
|
},
|
||||||
deckTitle: deck.title
|
deckTitle: deck.title
|
||||||
)
|
)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ struct TextbookExerciseView: View {
|
|||||||
}
|
}
|
||||||
grades = newGrades
|
grades = newGrades
|
||||||
isChecked = true
|
isChecked = true
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
saveAttempt(states: states, exerciseId: b.exerciseId ?? "")
|
saveAttempt(states: states, exerciseId: b.exerciseId ?? "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,7 +288,10 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadData() {
|
private func loadData() {
|
||||||
userProgress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
|
// Reset a stale streak before rendering so the dashboard never lies.
|
||||||
|
progress.validateStreakIfStale(context: cloudModelContext)
|
||||||
|
userProgress = progress
|
||||||
let dailyDescriptor = FetchDescriptor<DailyLog>(
|
let dailyDescriptor = FetchDescriptor<DailyLog>(
|
||||||
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
|
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct GrammarExerciseView: View {
|
struct GrammarExerciseView: View {
|
||||||
let noteId: String
|
let noteId: String
|
||||||
let noteTitle: String
|
let noteTitle: String
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
@State private var exercises: [GrammarExercise] = []
|
@State private var exercises: [GrammarExercise] = []
|
||||||
@State private var currentIndex = 0
|
@State private var currentIndex = 0
|
||||||
@@ -96,6 +99,7 @@ struct GrammarExerciseView: View {
|
|||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
selectedOption = nil
|
selectedOption = nil
|
||||||
} else {
|
} else {
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
withAnimation { isFinished = true }
|
withAnimation { isFinished = true }
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ struct VideoActionsButtonRow: View {
|
|||||||
|
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
@State private var downloadService = VideoDownloadService.shared
|
@State private var downloadService = VideoDownloadService.shared
|
||||||
@State private var isDownloaded: Bool
|
@State private var isDownloaded: Bool
|
||||||
@@ -89,6 +91,7 @@ struct VideoActionsButtonRow: View {
|
|||||||
private var streamButton: some View {
|
private var streamButton: some View {
|
||||||
Button {
|
Button {
|
||||||
if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") {
|
if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") {
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
openURL(url)
|
openURL(url)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -151,6 +154,7 @@ struct VideoActionsButtonRow: View {
|
|||||||
|
|
||||||
private var playButton: some View {
|
private var playButton: some View {
|
||||||
Button {
|
Button {
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
playerVideoId = video.videoId
|
playerVideoId = video.videoId
|
||||||
} label: {
|
} label: {
|
||||||
Label("Play", systemImage: "play.fill")
|
Label("Play", systemImage: "play.fill")
|
||||||
@@ -173,6 +177,7 @@ struct VideoActionsButtonRow: View {
|
|||||||
into: modelContext
|
into: modelContext
|
||||||
)
|
)
|
||||||
isDownloaded = true
|
isDownloaded = true
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
} catch {
|
} catch {
|
||||||
downloadError = error.localizedDescription
|
downloadError = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ struct ChatView: View {
|
|||||||
messages = conversation.decodedMessages
|
messages = conversation.decodedMessages
|
||||||
inputText = ""
|
inputText = ""
|
||||||
try? cloudContext.save()
|
try? cloudContext.save()
|
||||||
|
ReviewStore.recordActivity(context: cloudContext)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ struct ClozeView: View {
|
|||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
selectedOption = nil
|
selectedOption = nil
|
||||||
} else {
|
} else {
|
||||||
|
ReviewStore.recordActivity(context: cloudContext)
|
||||||
withAnimation { isFinished = true }
|
withAnimation { isFinished = true }
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ struct FullTableView: View {
|
|||||||
@State private var useHandwriting = false
|
@State private var useHandwriting = false
|
||||||
@State private var sessionCount = 0
|
@State private var sessionCount = 0
|
||||||
@State private var sessionCorrect = 0
|
@State private var sessionCorrect = 0
|
||||||
|
@State private var noEligibleVerbs = false
|
||||||
|
|
||||||
// Handwriting state per field
|
// Handwriting state per field
|
||||||
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
|
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
|
||||||
@@ -53,35 +54,39 @@ struct FullTableView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 32) {
|
if noEligibleVerbs {
|
||||||
// Header
|
emptyPoolError
|
||||||
if let verb = currentVerb, let tense = currentTense {
|
} else {
|
||||||
headerSection(verb: verb, tense: tense)
|
VStack(spacing: 32) {
|
||||||
}
|
// Header
|
||||||
|
if let verb = currentVerb, let tense = currentTense {
|
||||||
// Input mode toggle
|
headerSection(verb: verb, tense: tense)
|
||||||
HStack {
|
}
|
||||||
Picker("Input", selection: $useHandwriting) {
|
|
||||||
Label("Keyboard", systemImage: "keyboard").tag(false)
|
// Input mode toggle
|
||||||
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
|
HStack {
|
||||||
|
Picker("Input", selection: $useHandwriting) {
|
||||||
|
Label("Keyboard", systemImage: "keyboard").tag(false)
|
||||||
|
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Input fields
|
||||||
|
inputSection
|
||||||
|
|
||||||
|
// Check / Next button
|
||||||
|
actionButton
|
||||||
|
|
||||||
|
// Score
|
||||||
|
if sessionCount > 0 {
|
||||||
|
scoreSection
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
// Input fields
|
|
||||||
inputSection
|
|
||||||
|
|
||||||
// Check / Next button
|
|
||||||
actionButton
|
|
||||||
|
|
||||||
// Score
|
|
||||||
if sessionCount > 0 {
|
|
||||||
scoreSection
|
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer()
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.adaptiveContainer()
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Full Table")
|
.navigationTitle("Full Table")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -91,6 +96,22 @@ struct FullTableView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty pool error
|
||||||
|
|
||||||
|
private var emptyPoolError: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No regular verbs available",
|
||||||
|
systemImage: "exclamationmark.triangle",
|
||||||
|
description: Text(
|
||||||
|
"None of the selected tenses have any fully-regular verbs in the current settings. Enable more tenses, or turn off the Reflexive-only toggle in Settings."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
|
private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
|
||||||
@@ -249,13 +270,18 @@ struct FullTableView: View {
|
|||||||
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||||
)
|
)
|
||||||
guard let prompt = service.randomFullTablePrompt() else {
|
guard let prompt = service.randomFullTablePrompt() else {
|
||||||
|
// Genuinely no eligible (verb, tense) combo. Surface a clear error
|
||||||
|
// instead of a blank screen — the previous behaviour silently
|
||||||
|
// rendered an empty header and inputs.
|
||||||
currentVerb = nil
|
currentVerb = nil
|
||||||
currentTense = nil
|
currentTense = nil
|
||||||
userAnswers = Array(repeating: "", count: 6)
|
userAnswers = Array(repeating: "", count: 6)
|
||||||
focusedField = nil
|
focusedField = nil
|
||||||
|
noEligibleVerbs = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
noEligibleVerbs = false
|
||||||
currentVerb = prompt.verb
|
currentVerb = prompt.verb
|
||||||
currentTense = prompt.tenseInfo
|
currentTense = prompt.tenseInfo
|
||||||
correctForms = prompt.forms
|
correctForms = prompt.forms
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import SwiftData
|
|||||||
|
|
||||||
struct ListeningView: View {
|
struct ListeningView: View {
|
||||||
@Environment(\.modelContext) private var localContext
|
@Environment(\.modelContext) private var localContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
@State private var pronunciation = PronunciationService()
|
@State private var pronunciation = PronunciationService()
|
||||||
@State private var speechService = SpeechService()
|
@State private var speechService = SpeechService()
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ struct ListeningView: View {
|
|||||||
Button {
|
Button {
|
||||||
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
|
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
|
||||||
if result.score >= 0.7 { correctCount += 1 }
|
if result.score >= 0.7 { correctCount += 1 }
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
withAnimation { isRevealed = true }
|
withAnimation { isRevealed = true }
|
||||||
} label: {
|
} label: {
|
||||||
Text("Check")
|
Text("Check")
|
||||||
@@ -164,6 +167,7 @@ struct ListeningView: View {
|
|||||||
score = result.score
|
score = result.score
|
||||||
wordMatches = result.matches
|
wordMatches = result.matches
|
||||||
if result.score >= 0.7 { correctCount += 1 }
|
if result.score >= 0.7 { correctCount += 1 }
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
withAnimation { isRevealed = true }
|
withAnimation { isRevealed = true }
|
||||||
} else {
|
} else {
|
||||||
pronunciation.startRecording()
|
pronunciation.startRecording()
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
import SharedModels
|
import SharedModels
|
||||||
|
|
||||||
struct LyricsReaderView: View {
|
struct LyricsReaderView: View {
|
||||||
let song: SavedSong
|
let song: SavedSong
|
||||||
|
|
||||||
@Environment(DictionaryService.self) private var dictionary
|
@Environment(DictionaryService.self) private var dictionary
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
@State private var selectedWord: LyricsWordLookup?
|
@State private var selectedWord: LyricsWordLookup?
|
||||||
@State private var lookupCache: [String: LyricsWordLookup] = [:]
|
@State private var lookupCache: [String: LyricsWordLookup] = [:]
|
||||||
|
|
||||||
@@ -98,6 +101,7 @@ struct LyricsReaderView: View {
|
|||||||
return LyricsFlowLayout(spacing: 0) {
|
return LyricsFlowLayout(spacing: 0) {
|
||||||
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
|
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
|
||||||
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
|
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
selectedWord = word
|
selectedWord = word
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import SwiftData
|
|||||||
|
|
||||||
struct SentenceBuilderView: View {
|
struct SentenceBuilderView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
@State private var currentCard: VocabCard?
|
@State private var currentCard: VocabCard?
|
||||||
@State private var exampleIndex: Int = 0
|
@State private var exampleIndex: Int = 0
|
||||||
@@ -316,6 +318,7 @@ struct SentenceBuilderView: View {
|
|||||||
if isCorrect {
|
if isCorrect {
|
||||||
sessionCorrect += 1
|
sessionCorrect += 1
|
||||||
}
|
}
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
|
private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
import SharedModels
|
import SharedModels
|
||||||
|
|
||||||
struct StoryQuizView: View {
|
struct StoryQuizView: View {
|
||||||
let story: Story
|
let story: Story
|
||||||
|
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
@State private var currentIndex = 0
|
@State private var currentIndex = 0
|
||||||
@State private var selectedOption: Int?
|
@State private var selectedOption: Int?
|
||||||
@State private var correctCount = 0
|
@State private var correctCount = 0
|
||||||
@@ -85,6 +89,7 @@ struct StoryQuizView: View {
|
|||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
selectedOption = nil
|
selectedOption = nil
|
||||||
} else {
|
} else {
|
||||||
|
ReviewStore.recordActivity(context: cloudModelContext)
|
||||||
withAnimation { isFinished = true }
|
withAnimation { isFinished = true }
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ struct VocabReviewView: View {
|
|||||||
private func rate(quality: ReviewQuality) {
|
private func rate(quality: ReviewQuality) {
|
||||||
guard let card = dueCards[safe: currentIndex] else { return }
|
guard let card = dueCards[safe: currentIndex] else { return }
|
||||||
|
|
||||||
|
ReviewStore.recordActivity(context: cloudContext)
|
||||||
let store = CourseReviewStore(context: cloudContext)
|
let store = CourseReviewStore(context: cloudContext)
|
||||||
let result = SRSEngine.review(
|
let result = SRSEngine.review(
|
||||||
quality: quality,
|
quality: quality,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct FeatureReferenceView: View {
|
|||||||
title: "Full Table",
|
title: "Full Table",
|
||||||
details: [
|
details: [
|
||||||
"Shows all 6 person forms for one verb + tense",
|
"Shows all 6 person forms for one verb + tense",
|
||||||
"Random verb from your Level",
|
"Drawn from any regular verb — Level filter is ignored here on purpose, since regular conjugation patterns transfer across vocabulary",
|
||||||
"Random tense from your Enabled Tenses",
|
"Random tense from your Enabled Tenses",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -197,7 +197,7 @@ struct FeatureReferenceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Settings That Affect Practice") {
|
Section("Settings That Affect Practice") {
|
||||||
settingRow(name: "Level", affects: "Verb practice, Full Table, Quick Actions, Stories, Conversation")
|
settingRow(name: "Level", affects: "Verb practice, Quick Actions, Stories, Conversation (Full Table ignores level)")
|
||||||
settingRow(name: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories")
|
settingRow(name: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories")
|
||||||
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions")
|
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions")
|
||||||
settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")
|
settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")
|
||||||
|
|||||||
Reference in New Issue
Block a user