Fixes #28 — Curated reflexive verb list on detail + practice filter

Bundles the 100 most common reflexive verbs from spanishwithdaniel.com as a
canonical list and wires it through the UI. Compound list entries (recibirse
/ graduarse, equivocarse / confundirse) are split. Trailing prepositions and
set-phrase completions are captured as usageHint (e.g. acordarse "de",
ponerse "de acuerdo").

ReflexiveVerbStore loads the JSON at launch and exposes lookups by base
infinitive, both via @Environment for SwiftUI and a static shared instance
for services. Verbs whose bare infinitive isn't in the list skip the UI
treatment silently.

VerbDetailView shows a new Reflexive section with the reflexive infinitive,
usage hint, and English meaning when there is a match. VerbListView gains a
"Reflexive verbs only" filter alongside the existing Level and Irregularity
filters. Settings adds the same flag so it also constrains the practice
pool; PracticeSessionService applies the reflexive filter in all six pick
paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-22 10:56:04 -05:00
parent 4093b5a7f3
commit 98badc98ad
13 changed files with 360 additions and 23 deletions

View File

@@ -63,9 +63,11 @@
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; }; 8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; };
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */; }; 90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */; };
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; }; 943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */ = {isa = PBXBuildFile; fileRef = 3644B5ED77F29A65877D926A /* reflexive_verbs.json */; };
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; }; 97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; }; 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; }; 9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D1904DF07E0A6816134CF3 /* ListeningView.swift */; }; A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D1904DF07E0A6816134CF3 /* ListeningView.swift */; };
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; }; A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; }; AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
@@ -153,6 +155,7 @@
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; }; 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; }; 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
3644B5ED77F29A65877D926A /* reflexive_verbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reflexive_verbs.json; sourceTree = "<group>"; };
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; }; 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; };
39908548430FDF01D76201FB /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; }; 39908548430FDF01D76201FB /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; };
3A96C065B8787DEC6818E497 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; }; 3A96C065B8787DEC6818E497 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
@@ -197,6 +200,7 @@
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; }; 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; }; 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; }; 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = "<group>"; };
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; }; 9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -257,6 +261,7 @@
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */, 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
BC273716CD14A99EFF8206CA /* course_data.json */, BC273716CD14A99EFF8206CA /* course_data.json */,
7E6AF62A3A949630E067DC22 /* Info.plist */, 7E6AF62A3A949630E067DC22 /* Info.plist */,
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
353C5DE41FD410FA82E3AED7 /* Models */, 353C5DE41FD410FA82E3AED7 /* Models */,
1994867BC8E985795A172854 /* Services */, 1994867BC8E985795A172854 /* Services */,
BFC1AEBE02CE22E6474FFEA6 /* Utilities */, BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
@@ -298,6 +303,7 @@
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */, 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */, 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
777C696A841803D5B775B678 /* ReferenceStore.swift */, 777C696A841803D5B775B678 /* ReferenceStore.swift */,
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */,
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */, CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
49E3AD244327CBF24B7A2752 /* SpeechService.swift */, 49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */, 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
@@ -606,6 +612,7 @@
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */, F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */, CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */, 2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -663,6 +670,7 @@
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */, 0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */,
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */, 53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */, DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */,
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */, FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */, 728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */, 51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,

View File

@@ -41,6 +41,7 @@ struct ConjugaApp: App {
@State private var studyTimer = StudyTimerService() @State private var studyTimer = StudyTimerService()
@State private var dictionary = DictionaryService() @State private var dictionary = DictionaryService()
@State private var verbExampleCache = VerbExampleCache() @State private var verbExampleCache = VerbExampleCache()
@State private var reflexiveStore = ReflexiveVerbStore()
let localContainer: ModelContainer let localContainer: ModelContainer
let cloudContainer: ModelContainer let cloudContainer: ModelContainer
@@ -115,6 +116,7 @@ struct ConjugaApp: App {
.environment(studyTimer) .environment(studyTimer)
.environment(dictionary) .environment(dictionary)
.environment(verbExampleCache) .environment(verbExampleCache)
.environment(reflexiveStore)
.task { .task {
let needsSeed = await DataLoader.needsSeeding(container: localContainer) let needsSeed = await DataLoader.needsSeeding(container: localContainer)
if needsSeed { if needsSeed {

View File

@@ -14,6 +14,7 @@ final class UserProgress {
var selectedLevel: String = "basic" var selectedLevel: String = "basic"
var showVosotros: Bool = true var showVosotros: Bool = true
var autoFillStem: Bool = false var autoFillStem: Bool = false
var showReflexiveVerbsOnly: Bool = false
// Legacy CloudKit array-backed fields retained for migration compatibility. // Legacy CloudKit array-backed fields retained for migration compatibility.
var enabledTenses: [String] = [] var enabledTenses: [String] = []

View File

@@ -8,8 +8,10 @@ struct PracticeSettings: Sendable {
let enabledTenses: Set<String> let enabledTenses: Set<String>
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory> let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
let showVosotros: Bool let showVosotros: Bool
let showReflexiveVerbsOnly: Bool
let reflexiveBaseInfinitives: Set<String>
init(progress: UserProgress?) { init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
let resolvedTenses = progress?.enabledTenseIDs ?? [] let resolvedTenses = progress?.enabledTenseIDs ?? []
let resolvedLevels = progress?.selectedVerbLevels ?? [] let resolvedLevels = progress?.selectedVerbLevels ?? []
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
@@ -17,6 +19,8 @@ struct PracticeSettings: Sendable {
self.enabledTenses = Set(resolvedTenses) self.enabledTenses = Set(resolvedTenses)
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? [] self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
self.showVosotros = progress?.showVosotros ?? true self.showVosotros = progress?.showVosotros ?? true
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
} }
var selectionTenseIDs: [String] { var selectionTenseIDs: [String] {
@@ -41,16 +45,25 @@ struct FullTablePrompt {
struct PracticeSessionService { struct PracticeSessionService {
let localContext: ModelContext let localContext: ModelContext
let cloudContext: ModelContext let cloudContext: ModelContext
let reflexiveBaseInfinitives: Set<String>
private let referenceStore: ReferenceStore private let referenceStore: ReferenceStore
init(localContext: ModelContext, cloudContext: ModelContext) { init(
localContext: ModelContext,
cloudContext: ModelContext,
reflexiveBaseInfinitives: Set<String> = []
) {
self.localContext = localContext self.localContext = localContext
self.cloudContext = cloudContext self.cloudContext = cloudContext
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
self.referenceStore = ReferenceStore(context: localContext) self.referenceStore = ReferenceStore(context: localContext)
} }
func settings() -> PracticeSettings { func settings() -> PracticeSettings {
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext)) PracticeSettings(
progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext),
reflexiveBaseInfinitives: reflexiveBaseInfinitives
)
} }
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? { func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
@@ -86,7 +99,10 @@ struct PracticeSessionService {
let settings = settings() let settings = settings()
// Full Table practice is regular-only, so the irregular-category setting is // Full Table practice is regular-only, so the irregular-category setting is
// deliberately ignored here (applying it would empty the pool). // deliberately ignored here (applying it would empty the pool).
let verbs = referenceStore.fetchVerbs(selectedLevels: settings.selectedLevels) let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(selectedLevels: settings.selectedLevels),
settings: settings
)
guard !verbs.isEmpty else { return nil } guard !verbs.isEmpty else { return nil }
for _ in 0..<40 { for _ in 0..<40 {
@@ -143,6 +159,27 @@ struct PracticeSessionService {
return buildCardLoad(verb: verb, form: form) return buildCardLoad(verb: verb, form: form)
} }
/// When the user has "Reflexive verbs only" enabled, restrict the allowed
/// verb-id set to IDs whose infinitive is in the curated list.
/// No-op otherwise.
private func applyReflexiveFilter(to ids: Set<Int>, settings: PracticeSettings) -> Set<Int> {
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
return ids
}
let matching = ids.filter { id in
guard let verb = referenceStore.fetchVerb(id: id) else { return false }
return settings.reflexiveBaseInfinitives.contains(verb.infinitive.lowercased())
}
return matching
}
private func applyReflexiveFilter(to verbs: [Verb], settings: PracticeSettings) -> [Verb] {
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
return verbs
}
return verbs.filter { settings.reflexiveBaseInfinitives.contains($0.infinitive.lowercased()) }
}
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad { private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
let spans = referenceStore.fetchSpans( let spans = referenceStore.fetchSpans(
verbId: form.verbId, verbId: form.verbId,
@@ -164,9 +201,12 @@ struct PracticeSessionService {
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? { private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
let settings = settings() let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs( let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels, selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories irregularCategories: settings.enabledIrregularCategories
),
settings: settings
) )
let now = Date() let now = Date()
var descriptor = FetchDescriptor<ReviewCard>( var descriptor = FetchDescriptor<ReviewCard>(
@@ -194,9 +234,12 @@ struct PracticeSessionService {
private func pickWeakForm() -> VerbForm? { private func pickWeakForm() -> VerbForm? {
let settings = settings() let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs( let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels, selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories irregularCategories: settings.enabledIrregularCategories
),
settings: settings
) )
let descriptor = FetchDescriptor<ReviewCard>( let descriptor = FetchDescriptor<ReviewCard>(
@@ -221,9 +264,12 @@ struct PracticeSessionService {
let settings = settings() let settings = settings()
// Focus mode explicitly selects one irregular category, so the user's // Focus mode explicitly selects one irregular category, so the user's
// settings-level irregular filter is deliberately skipped here. // settings-level irregular filter is deliberately skipped here.
let allowedVerbIds = referenceStore.allowedVerbIDs( let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels, selectedLevels: settings.selectedLevels,
irregularCategories: [] irregularCategories: []
),
settings: settings
) )
let typeRange: ClosedRange<Int> let typeRange: ClosedRange<Int>
@@ -261,9 +307,12 @@ struct PracticeSessionService {
private func pickCommonTenseForm() -> VerbForm? { private func pickCommonTenseForm() -> VerbForm? {
let settings = settings() let settings = settings()
let coreTenseIDs = TenseID.coreTenseIDs let coreTenseIDs = TenseID.coreTenseIDs
let verbs = referenceStore.fetchVerbs( let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels, selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories irregularCategories: settings.enabledIrregularCategories
),
settings: settings
) )
guard let verb = verbs.randomElement() else { return nil } guard let verb = verbs.randomElement() else { return nil }
@@ -277,9 +326,12 @@ struct PracticeSessionService {
private func pickRandomForm() -> VerbForm? { private func pickRandomForm() -> VerbForm? {
let settings = settings() let settings = settings()
let verbs = referenceStore.fetchVerbs( let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels, selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories irregularCategories: settings.enabledIrregularCategories
),
settings: settings
) )
guard let verb = verbs.randomElement() else { return nil } guard let verb = verbs.randomElement() else { return nil }

View File

@@ -0,0 +1,59 @@
import Foundation
import SharedModels
/// Loads and queries the curated reflexive-verb list bundled with the app
/// (Gitea issue #28). One JSON load at init; in-memory lookup thereafter.
///
/// `entries(for:)` returns a list because a single base infinitive may map to
/// multiple reflexive entries e.g., `ponerse` covers both "to put on
/// (clothing) / to become" and "to come to an agreement (with)".
@MainActor
@Observable
final class ReflexiveVerbStore {
/// Process-wide accessor for services that can't use @Environment injection
/// (e.g. PracticeSessionService called from ViewModels). Views should still
/// prefer @Environment(ReflexiveVerbStore.self) for consistency.
static let shared = ReflexiveVerbStore()
private(set) var entries: [ReflexiveVerb] = []
private var indexByBase: [String: [ReflexiveVerb]] = [:]
/// Set of base infinitives present in the list. Cheap lookup for filters.
private(set) var baseInfinitives: Set<String> = []
init(bundle: Bundle = .main) {
load(from: bundle)
}
/// All reflexive entries whose base infinitive matches (case-insensitive).
func entries(for baseInfinitive: String) -> [ReflexiveVerb] {
indexByBase[baseInfinitive.lowercased()] ?? []
}
/// Convenience true when the verb's bare infinitive appears in the list.
func isReflexive(baseInfinitive: String) -> Bool {
baseInfinitives.contains(baseInfinitive.lowercased())
}
private func load(from bundle: Bundle) {
guard let url = bundle.url(forResource: "reflexive_verbs", withExtension: "json"),
let data = try? Data(contentsOf: url) else {
print("[ReflexiveVerbStore] bundled reflexive_verbs.json not found")
return
}
do {
let decoded = try JSONDecoder().decode([ReflexiveVerb].self, from: data)
entries = decoded
var index: [String: [ReflexiveVerb]] = [:]
for entry in decoded {
index[entry.baseInfinitive.lowercased(), default: []].append(entry)
}
indexByBase = index
baseInfinitives = Set(index.keys)
print("[ReflexiveVerbStore] loaded \(decoded.count) entries (\(baseInfinitives.count) distinct base infinitives)")
} catch {
print("[ReflexiveVerbStore] decode failed: \(error)")
}
}
}

View File

@@ -96,7 +96,11 @@ final class PracticeViewModel {
currentSpans = [] currentSpans = []
hasCards = true hasCards = true
isLoading = true isLoading = true
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext) let service = PracticeSessionService(
localContext: localContext,
cloudContext: cloudContext,
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
)
guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else { guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else {
clearCurrentCard() clearCurrentCard()
hasCards = false hasCards = false

View File

@@ -243,7 +243,11 @@ struct FullTableView: View {
results = Array(repeating: nil, count: 6) results = Array(repeating: nil, count: 6)
correctForms = [] correctForms = []
drawings = Array(repeating: PKDrawing(), count: 6) drawings = Array(repeating: PKDrawing(), count: 6)
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext) let service = PracticeSessionService(
localContext: modelContext,
cloudContext: cloudModelContext,
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
)
guard let prompt = service.randomFullTablePrompt() else { guard let prompt = service.randomFullTablePrompt() else {
currentVerb = nil currentVerb = nil
currentTense = nil currentTense = nil
@@ -312,7 +316,11 @@ struct FullTableView: View {
if allCorrect { sessionCorrect += 1 } if allCorrect { sessionCorrect += 1 }
if let verb = currentVerb, let tense = currentTense { if let verb = currentVerb, let tense = currentTense {
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext) let service = PracticeSessionService(
localContext: modelContext,
cloudContext: cloudModelContext,
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
)
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) }) let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults) _ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
} }

View File

@@ -97,6 +97,20 @@ struct SettingsView: View {
Text("Leave all off to include regular and irregular verbs. Enable any to restrict practice to those irregularity types.") Text("Leave all off to include regular and irregular verbs. Enable any to restrict practice to those irregularity types.")
} }
Section {
Toggle("Reflexive verbs only", isOn: Binding(
get: { progress?.showReflexiveVerbsOnly ?? false },
set: { enabled in
progress?.showReflexiveVerbsOnly = enabled
saveProgress()
}
))
} header: {
Text("Reflexive")
} footer: {
Text("When on, practice pulls only from the curated list of common reflexive verbs.")
}
Section("Stats") { Section("Stats") {
if let progress { if let progress {
LabeledContent("Total Reviewed", value: "\(progress.totalReviewed)") LabeledContent("Total Reviewed", value: "\(progress.totalReviewed)")

View File

@@ -5,6 +5,7 @@ import SwiftData
struct VerbDetailView: View { struct VerbDetailView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(VerbExampleCache.self) private var exampleCache @Environment(VerbExampleCache.self) private var exampleCache
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
let verb: Verb let verb: Verb
@State private var selectedTense: TenseInfo = TenseInfo.all[0] @State private var selectedTense: TenseInfo = TenseInfo.all[0]
@@ -33,6 +34,10 @@ struct VerbDetailView: View {
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id) ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
} }
private var reflexiveEntries: [ReflexiveVerb] {
reflexiveStore.entries(for: verb.infinitive)
}
var body: some View { var body: some View {
List { List {
Section { Section {
@@ -46,6 +51,10 @@ struct VerbDetailView: View {
Text("Info") Text("Info")
} }
if !reflexiveEntries.isEmpty {
reflexiveSection
}
Section { Section {
Picker("Tense", selection: $selectedTense) { Picker("Tense", selection: $selectedTense) {
ForEach(TenseInfo.all) { tense in ForEach(TenseInfo.all) { tense in
@@ -106,6 +115,38 @@ struct VerbDetailView: View {
} }
} }
// MARK: - Reflexive
private var reflexiveSection: some View {
Section {
ForEach(Array(reflexiveEntries.enumerated()), id: \.offset) { _, entry in
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(entry.infinitive)
.font(.body.weight(.semibold))
.italic()
if let hint = entry.usageHint, !hint.isEmpty {
Text(hint)
.font(.caption.weight(.medium))
.foregroundStyle(.tint)
}
}
Text(entry.english)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
} header: {
Text("Reflexive")
} footer: {
if reflexiveEntries.contains(where: { $0.usageHint != nil }) {
Text("Highlighted words are prepositions or phrases this verb commonly pairs with.")
.font(.caption2)
}
}
}
// MARK: - Examples // MARK: - Examples
@ViewBuilder @ViewBuilder
@@ -198,4 +239,5 @@ struct VerbDetailView: View {
} }
.modelContainer(for: [Verb.self, VerbForm.self], inMemory: true) .modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
.environment(VerbExampleCache()) .environment(VerbExampleCache())
.environment(ReflexiveVerbStore())
} }

View File

@@ -22,11 +22,13 @@ enum IrregularityCategory: String, CaseIterable, Identifiable {
struct VerbListView: View { struct VerbListView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var verbs: [Verb] = [] @State private var verbs: [Verb] = []
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:] @State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
@State private var searchText = "" @State private var searchText = ""
@State private var selectedLevel: String? @State private var selectedLevel: String?
@State private var selectedIrregularity: IrregularityCategory? @State private var selectedIrregularity: IrregularityCategory?
@State private var reflexiveOnly: Bool = false
@State private var selectedVerb: Verb? @State private var selectedVerb: Verb?
private var filteredVerbs: [Verb] { private var filteredVerbs: [Verb] {
@@ -40,6 +42,9 @@ struct VerbListView: View {
return category == .anyIrregular ? !cats.isEmpty : cats.contains(category) return category == .anyIrregular ? !cats.isEmpty : cats.contains(category)
} }
} }
if reflexiveOnly {
result = result.filter { reflexiveStore.isReflexive(baseInfinitive: $0.infinitive) }
}
if !searchText.isEmpty { if !searchText.isEmpty {
let query = searchText.lowercased() let query = searchText.lowercased()
result = result.filter { result = result.filter {
@@ -98,6 +103,14 @@ struct VerbListView: View {
} }
} }
} }
Section("Reflexive") {
Button {
reflexiveOnly.toggle()
} label: {
Label("Reflexive verbs only", systemImage: reflexiveOnly ? "checkmark" : "arrow.triangle.2.circlepath")
}
}
} label: { } label: {
Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
} }
@@ -115,7 +128,7 @@ struct VerbListView: View {
} }
private var hasActiveFilter: Bool { private var hasActiveFilter: Bool {
selectedLevel != nil || selectedIrregularity != nil selectedLevel != nil || selectedIrregularity != nil || reflexiveOnly
} }
@ViewBuilder @ViewBuilder
@@ -131,6 +144,11 @@ struct VerbListView: View {
selectedIrregularity = nil selectedIrregularity = nil
} }
} }
if reflexiveOnly {
filterChip(text: "Reflexive", systemImage: "arrow.triangle.2.circlepath") {
reflexiveOnly = false
}
}
Spacer() Spacer()
Text("\(filteredVerbs.count)") Text("\(filteredVerbs.count)")
.font(.caption.monospacedDigit()) .font(.caption.monospacedDigit())

View File

@@ -0,0 +1,104 @@
[
{"infinitive": "aburrirse", "baseInfinitive": "aburrir", "english": "to get bored"},
{"infinitive": "acercarse", "baseInfinitive": "acercar", "english": "to get close to", "usageHint": "a"},
{"infinitive": "acordarse", "baseInfinitive": "acordar", "english": "to remember", "usageHint": "de"},
{"infinitive": "acostarse", "baseInfinitive": "acostar", "english": "to lay down / to go to bed"},
{"infinitive": "acostumbrarse", "baseInfinitive": "acostumbrar", "english": "to get used to", "usageHint": "a"},
{"infinitive": "afeitarse", "baseInfinitive": "afeitar", "english": "to shave"},
{"infinitive": "alegrarse", "baseInfinitive": "alegrar", "english": "to be glad / happy / pleased"},
{"infinitive": "alejarse", "baseInfinitive": "alejar", "english": "to get away from", "usageHint": "de"},
{"infinitive": "animarse", "baseInfinitive": "animar", "english": "to cheer up / to dare to do something", "usageHint": "a"},
{"infinitive": "apurarse", "baseInfinitive": "apurar", "english": "to hurry"},
{"infinitive": "aprovecharse", "baseInfinitive": "aprovechar", "english": "to take advantage of", "usageHint": "de"},
{"infinitive": "asustarse", "baseInfinitive": "asustar", "english": "to get or become afraid"},
{"infinitive": "atreverse", "baseInfinitive": "atrever", "english": "to dare to", "usageHint": "a"},
{"infinitive": "bañarse", "baseInfinitive": "bañar", "english": "to take a bath / shower"},
{"infinitive": "burlarse", "baseInfinitive": "burlar", "english": "to make fun of", "usageHint": "de"},
{"infinitive": "caerse", "baseInfinitive": "caer", "english": "to fall down"},
{"infinitive": "calmarse", "baseInfinitive": "calmar", "english": "to calm down"},
{"infinitive": "cansarse", "baseInfinitive": "cansar", "english": "to get tired (of)", "usageHint": "(de)"},
{"infinitive": "casarse", "baseInfinitive": "casar", "english": "to marry", "usageHint": "con"},
{"infinitive": "cepillarse", "baseInfinitive": "cepillar", "english": "to brush (hair, teeth)"},
{"infinitive": "deprimirse", "baseInfinitive": "deprimir", "english": "to become depressed"},
{"infinitive": "conformarse", "baseInfinitive": "conformar", "english": "to resign oneself to", "usageHint": "con"},
{"infinitive": "volverse", "baseInfinitive": "volver", "english": "to become / to turn into / to return"},
{"infinitive": "darse", "baseInfinitive": "dar", "english": "to realize", "usageHint": "cuenta de"},
{"infinitive": "dedicarse", "baseInfinitive": "dedicar", "english": "to dedicate oneself to / to do for a living", "usageHint": "a"},
{"infinitive": "despedirse", "baseInfinitive": "despedir", "english": "to say goodbye", "usageHint": "(de)"},
{"infinitive": "despertarse", "baseInfinitive": "despertar", "english": "to wake up"},
{"infinitive": "desvestirse", "baseInfinitive": "desvestir", "english": "to undress"},
{"infinitive": "dirigirse", "baseInfinitive": "dirigir", "english": "to go to / make one's way toward / to address", "usageHint": "a"},
{"infinitive": "hacerse", "baseInfinitive": "hacer", "english": "to become / to pretend"},
{"infinitive": "divertirse", "baseInfinitive": "divertir", "english": "to have fun"},
{"infinitive": "dormirse", "baseInfinitive": "dormir", "english": "to fall asleep / to oversleep"},
{"infinitive": "ducharse", "baseInfinitive": "duchar", "english": "to shower"},
{"infinitive": "echarse", "baseInfinitive": "echar", "english": "to begin (usually suddenly) to do something / to break into", "usageHint": "a"},
{"infinitive": "enamorarse", "baseInfinitive": "enamorar", "english": "to fall in love with", "usageHint": "de"},
{"infinitive": "encargarse", "baseInfinitive": "encargar", "english": "to take charge of or be responsible for", "usageHint": "de"},
{"infinitive": "encogerse", "baseInfinitive": "encoger", "english": "to shrug (shoulders)", "usageHint": "(de hombros)"},
{"infinitive": "encontrarse", "baseInfinitive": "encontrar", "english": "to meet with / to run into someone", "usageHint": "(con)"},
{"infinitive": "enfermarse", "baseInfinitive": "enfermar", "english": "to get sick"},
{"infinitive": "enojarse", "baseInfinitive": "enojar", "english": "to get or become angry"},
{"infinitive": "enterarse", "baseInfinitive": "enterar", "english": "to find out, to realize", "usageHint": "de"},
{"infinitive": "exponerse", "baseInfinitive": "exponer", "english": "to expose oneself to or run the risk of", "usageHint": "a"},
{"infinitive": "fijarse", "baseInfinitive": "fijar", "english": "to pay attention to / to take a look"},
{"infinitive": "jugarse", "baseInfinitive": "jugar", "english": "to risk"},
{"infinitive": "lastimarse", "baseInfinitive": "lastimar", "english": "to get hurt or hurt oneself"},
{"infinitive": "lavarse", "baseInfinitive": "lavar", "english": "to wash (a body part)"},
{"infinitive": "levantarse", "baseInfinitive": "levantar", "english": "to get up"},
{"infinitive": "maquillarse", "baseInfinitive": "maquillar", "english": "to put makeup on"},
{"infinitive": "meterse", "baseInfinitive": "meter", "english": "to get into / to pick on / to pick a fight with", "usageHint": "en / con"},
{"infinitive": "motivarse", "baseInfinitive": "motivar", "english": "to become or get motivated to"},
{"infinitive": "moverse", "baseInfinitive": "mover", "english": "to move oneself"},
{"infinitive": "mudarse", "baseInfinitive": "mudar", "english": "to move (change residence)"},
{"infinitive": "negarse", "baseInfinitive": "negar", "english": "to refuse to", "usageHint": "a"},
{"infinitive": "obsesionarse", "baseInfinitive": "obsesionar", "english": "to be or get obsessed with", "usageHint": "con"},
{"infinitive": "ocuparse", "baseInfinitive": "ocupar", "english": "to look after", "usageHint": "de"},
{"infinitive": "olvidarse", "baseInfinitive": "olvidar", "english": "to forget", "usageHint": "de"},
{"infinitive": "parecerse", "baseInfinitive": "parecer", "english": "to look like someone or something", "usageHint": "a"},
{"infinitive": "peinarse", "baseInfinitive": "peinar", "english": "to comb your hair"},
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to put on (clothing) / to get or become"},
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to come to an agreement with someone", "usageHint": "de acuerdo"},
{"infinitive": "preocuparse", "baseInfinitive": "preocupar", "english": "to worry about", "usageHint": "por"},
{"infinitive": "prepararse", "baseInfinitive": "preparar", "english": "to prepare to"},
{"infinitive": "probarse", "baseInfinitive": "probar", "english": "to try on"},
{"infinitive": "quebrarse", "baseInfinitive": "quebrar", "english": "to break (an arm, leg, etc.)"},
{"infinitive": "quejarse", "baseInfinitive": "quejar", "english": "to complain about", "usageHint": "de"},
{"infinitive": "quedarse", "baseInfinitive": "quedar", "english": "to remain / to stay"},
{"infinitive": "quemarse", "baseInfinitive": "quemar", "english": "to burn oneself / one's body"},
{"infinitive": "quitarse", "baseInfinitive": "quitar", "english": "to take off (clothing, etc.)"},
{"infinitive": "reírse", "baseInfinitive": "reír", "english": "to laugh about", "usageHint": "de"},
{"infinitive": "resignarse", "baseInfinitive": "resignar", "english": "to resign oneself to", "usageHint": "a"},
{"infinitive": "romperse", "baseInfinitive": "romper", "english": "to break (an arm, leg, etc.)"},
{"infinitive": "secarse", "baseInfinitive": "secar", "english": "to dry (a body part)"},
{"infinitive": "sentarse", "baseInfinitive": "sentar", "english": "to sit down"},
{"infinitive": "sentirse", "baseInfinitive": "sentir", "english": "to feel"},
{"infinitive": "servirse", "baseInfinitive": "servir", "english": "to help oneself to (food)"},
{"infinitive": "suicidarse", "baseInfinitive": "suicidar", "english": "to commit suicide"},
{"infinitive": "tratarse", "baseInfinitive": "tratar", "english": "to be about", "usageHint": "de"},
{"infinitive": "vestirse", "baseInfinitive": "vestir", "english": "to get dressed"},
{"infinitive": "marearse", "baseInfinitive": "marear", "english": "to get sick, to get dizzy"},
{"infinitive": "irse", "baseInfinitive": "ir", "english": "to leave"},
{"infinitive": "imaginarse", "baseInfinitive": "imaginar", "english": "to imagine"},
{"infinitive": "preguntarse", "baseInfinitive": "preguntar", "english": "to wonder"},
{"infinitive": "llamarse", "baseInfinitive": "llamar", "english": "to be called"},
{"infinitive": "verse", "baseInfinitive": "ver", "english": "to look or appear"},
{"infinitive": "distraerse", "baseInfinitive": "distraer", "english": "to get distracted"},
{"infinitive": "concentrarse", "baseInfinitive": "concentrar", "english": "to focus"},
{"infinitive": "rendirse", "baseInfinitive": "rendir", "english": "to give up"},
{"infinitive": "relajarse", "baseInfinitive": "relajar", "english": "to relax"},
{"infinitive": "merecerse", "baseInfinitive": "merecer", "english": "to deserve"},
{"infinitive": "suponerse", "baseInfinitive": "suponer", "english": "to suppose"},
{"infinitive": "conectarse", "baseInfinitive": "conectar", "english": "to connect"},
{"infinitive": "destacarse", "baseInfinitive": "destacar", "english": "to stand out"},
{"infinitive": "recibirse", "baseInfinitive": "recibir", "english": "to graduate"},
{"infinitive": "graduarse", "baseInfinitive": "graduar", "english": "to graduate"},
{"infinitive": "perderse", "baseInfinitive": "perder", "english": "to get lost"},
{"infinitive": "cambiarse", "baseInfinitive": "cambiar", "english": "to change (clothing)", "usageHint": "(de ropa)"},
{"infinitive": "adaptarse", "baseInfinitive": "adaptar", "english": "to adapt, to adjust", "usageHint": "a"},
{"infinitive": "salirse", "baseInfinitive": "salir", "english": "to get away with", "usageHint": "con (la suya)"},
{"infinitive": "subirse", "baseInfinitive": "subir", "english": "to get on (the bus, etc.)", "usageHint": "a"},
{"infinitive": "tranquilizarse", "baseInfinitive": "tranquilizar", "english": "to relax"},
{"infinitive": "equivocarse", "baseInfinitive": "equivocar", "english": "to get something wrong / confused"},
{"infinitive": "confundirse", "baseInfinitive": "confundir", "english": "to get something wrong / confused"}
]

View File

@@ -0,0 +1,23 @@
import Foundation
/// A single entry from the curated "100 most common reflexive verbs" list
/// (Gitea issue #28). Sourced from spanishwithdaniel.com.
///
/// `baseInfinitive` is the stem without the reflexive "-se" suffix, used to
/// match this entry to the app's Verb records (which store bare infinitives).
/// `usageHint` captures trailing prepositions or set-phrase completions e.g.,
/// "a" for `acercarse a`, "de acuerdo" for `ponerse de acuerdo`. Nil when the
/// reflexive form has no commonly paired preposition.
public struct ReflexiveVerb: Codable, Hashable, Sendable {
public let infinitive: String
public let baseInfinitive: String
public let english: String
public let usageHint: String?
public init(infinitive: String, baseInfinitive: String, english: String, usageHint: String? = nil) {
self.infinitive = infinitive
self.baseInfinitive = baseInfinitive
self.english = english
self.usageHint = usageHint
}
}

View File

@@ -47,6 +47,8 @@ targets:
buildPhase: resources buildPhase: resources
- path: Conjuga/course_data.json - path: Conjuga/course_data.json
buildPhase: resources buildPhase: resources
- path: Conjuga/reflexive_verbs.json
buildPhase: resources
info: info:
path: Conjuga/Info.plist path: Conjuga/Info.plist
properties: properties: