diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index b7ac5ba..ae3e06e 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -63,9 +63,11 @@ 8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; }; 90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.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 */; }; 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.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 */; }; A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.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 = ""; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = ""; }; 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = ""; }; + 3644B5ED77F29A65877D926A /* reflexive_verbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reflexive_verbs.json; sourceTree = ""; }; 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = ""; }; 39908548430FDF01D76201FB /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = ""; }; 3A96C065B8787DEC6818E497 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = ""; }; @@ -197,6 +200,7 @@ 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = ""; }; 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = ""; }; 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = ""; }; + 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = ""; }; 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 = ""; }; A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -257,6 +261,7 @@ 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */, BC273716CD14A99EFF8206CA /* course_data.json */, 7E6AF62A3A949630E067DC22 /* Info.plist */, + 3644B5ED77F29A65877D926A /* reflexive_verbs.json */, 353C5DE41FD410FA82E3AED7 /* Models */, 1994867BC8E985795A172854 /* Services */, BFC1AEBE02CE22E6474FFEA6 /* Utilities */, @@ -298,6 +303,7 @@ 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */, 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */, 777C696A841803D5B775B678 /* ReferenceStore.swift */, + 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */, CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */, 49E3AD244327CBF24B7A2752 /* SpeechService.swift */, 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */, @@ -606,6 +612,7 @@ F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */, CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */, 2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */, + 97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -663,6 +670,7 @@ 0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */, 53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */, DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */, + A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */, FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */, 728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */, 51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */, diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index 21827a7..11e206e 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -41,6 +41,7 @@ struct ConjugaApp: App { @State private var studyTimer = StudyTimerService() @State private var dictionary = DictionaryService() @State private var verbExampleCache = VerbExampleCache() + @State private var reflexiveStore = ReflexiveVerbStore() let localContainer: ModelContainer let cloudContainer: ModelContainer @@ -115,6 +116,7 @@ struct ConjugaApp: App { .environment(studyTimer) .environment(dictionary) .environment(verbExampleCache) + .environment(reflexiveStore) .task { let needsSeed = await DataLoader.needsSeeding(container: localContainer) if needsSeed { diff --git a/Conjuga/Conjuga/Models/UserProgress.swift b/Conjuga/Conjuga/Models/UserProgress.swift index 1d34f35..b4d4733 100644 --- a/Conjuga/Conjuga/Models/UserProgress.swift +++ b/Conjuga/Conjuga/Models/UserProgress.swift @@ -14,6 +14,7 @@ final class UserProgress { var selectedLevel: String = "basic" var showVosotros: Bool = true var autoFillStem: Bool = false + var showReflexiveVerbsOnly: Bool = false // Legacy CloudKit array-backed fields retained for migration compatibility. var enabledTenses: [String] = [] diff --git a/Conjuga/Conjuga/Services/PracticeSessionService.swift b/Conjuga/Conjuga/Services/PracticeSessionService.swift index 637db72..7e7bf4d 100644 --- a/Conjuga/Conjuga/Services/PracticeSessionService.swift +++ b/Conjuga/Conjuga/Services/PracticeSessionService.swift @@ -8,8 +8,10 @@ struct PracticeSettings: Sendable { let enabledTenses: Set let enabledIrregularCategories: Set let showVosotros: Bool + let showReflexiveVerbsOnly: Bool + let reflexiveBaseInfinitives: Set - init(progress: UserProgress?) { + init(progress: UserProgress?, reflexiveBaseInfinitives: Set = []) { let resolvedTenses = progress?.enabledTenseIDs ?? [] let resolvedLevels = progress?.selectedVerbLevels ?? [] self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue @@ -17,6 +19,8 @@ struct PracticeSettings: Sendable { self.enabledTenses = Set(resolvedTenses) self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? [] self.showVosotros = progress?.showVosotros ?? true + self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false + self.reflexiveBaseInfinitives = reflexiveBaseInfinitives } var selectionTenseIDs: [String] { @@ -41,16 +45,25 @@ struct FullTablePrompt { struct PracticeSessionService { let localContext: ModelContext let cloudContext: ModelContext + let reflexiveBaseInfinitives: Set private let referenceStore: ReferenceStore - init(localContext: ModelContext, cloudContext: ModelContext) { + init( + localContext: ModelContext, + cloudContext: ModelContext, + reflexiveBaseInfinitives: Set = [] + ) { self.localContext = localContext self.cloudContext = cloudContext + self.reflexiveBaseInfinitives = reflexiveBaseInfinitives self.referenceStore = ReferenceStore(context: localContext) } 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? { @@ -86,7 +99,10 @@ struct PracticeSessionService { let settings = settings() // Full Table practice is regular-only, so the irregular-category setting is // 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 } for _ in 0..<40 { @@ -143,6 +159,27 @@ struct PracticeSessionService { 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, settings: PracticeSettings) -> Set { + 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 { let spans = referenceStore.fetchSpans( verbId: form.verbId, @@ -164,9 +201,12 @@ struct PracticeSessionService { private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? { let settings = settings() - let allowedVerbIds = referenceStore.allowedVerbIDs( - selectedLevels: settings.selectedLevels, - irregularCategories: settings.enabledIrregularCategories + let allowedVerbIds = applyReflexiveFilter( + to: referenceStore.allowedVerbIDs( + selectedLevels: settings.selectedLevels, + irregularCategories: settings.enabledIrregularCategories + ), + settings: settings ) let now = Date() var descriptor = FetchDescriptor( @@ -194,9 +234,12 @@ struct PracticeSessionService { private func pickWeakForm() -> VerbForm? { let settings = settings() - let allowedVerbIds = referenceStore.allowedVerbIDs( - selectedLevels: settings.selectedLevels, - irregularCategories: settings.enabledIrregularCategories + let allowedVerbIds = applyReflexiveFilter( + to: referenceStore.allowedVerbIDs( + selectedLevels: settings.selectedLevels, + irregularCategories: settings.enabledIrregularCategories + ), + settings: settings ) let descriptor = FetchDescriptor( @@ -221,9 +264,12 @@ struct PracticeSessionService { let settings = settings() // Focus mode explicitly selects one irregular category, so the user's // settings-level irregular filter is deliberately skipped here. - let allowedVerbIds = referenceStore.allowedVerbIDs( - selectedLevels: settings.selectedLevels, - irregularCategories: [] + let allowedVerbIds = applyReflexiveFilter( + to: referenceStore.allowedVerbIDs( + selectedLevels: settings.selectedLevels, + irregularCategories: [] + ), + settings: settings ) let typeRange: ClosedRange @@ -261,9 +307,12 @@ struct PracticeSessionService { private func pickCommonTenseForm() -> VerbForm? { let settings = settings() let coreTenseIDs = TenseID.coreTenseIDs - let verbs = referenceStore.fetchVerbs( - selectedLevels: settings.selectedLevels, - irregularCategories: settings.enabledIrregularCategories + let verbs = applyReflexiveFilter( + to: referenceStore.fetchVerbs( + selectedLevels: settings.selectedLevels, + irregularCategories: settings.enabledIrregularCategories + ), + settings: settings ) guard let verb = verbs.randomElement() else { return nil } @@ -277,9 +326,12 @@ struct PracticeSessionService { private func pickRandomForm() -> VerbForm? { let settings = settings() - let verbs = referenceStore.fetchVerbs( - selectedLevels: settings.selectedLevels, - irregularCategories: settings.enabledIrregularCategories + let verbs = applyReflexiveFilter( + to: referenceStore.fetchVerbs( + selectedLevels: settings.selectedLevels, + irregularCategories: settings.enabledIrregularCategories + ), + settings: settings ) guard let verb = verbs.randomElement() else { return nil } diff --git a/Conjuga/Conjuga/Services/ReflexiveVerbStore.swift b/Conjuga/Conjuga/Services/ReflexiveVerbStore.swift new file mode 100644 index 0000000..8606630 --- /dev/null +++ b/Conjuga/Conjuga/Services/ReflexiveVerbStore.swift @@ -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 = [] + + 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)") + } + } +} diff --git a/Conjuga/Conjuga/ViewModels/PracticeViewModel.swift b/Conjuga/Conjuga/ViewModels/PracticeViewModel.swift index b5943a0..182c1de 100644 --- a/Conjuga/Conjuga/ViewModels/PracticeViewModel.swift +++ b/Conjuga/Conjuga/ViewModels/PracticeViewModel.swift @@ -96,7 +96,11 @@ final class PracticeViewModel { currentSpans = [] hasCards = 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 { clearCurrentCard() hasCards = false diff --git a/Conjuga/Conjuga/Views/Practice/FullTableView.swift b/Conjuga/Conjuga/Views/Practice/FullTableView.swift index 9577f0d..c30c455 100644 --- a/Conjuga/Conjuga/Views/Practice/FullTableView.swift +++ b/Conjuga/Conjuga/Views/Practice/FullTableView.swift @@ -243,7 +243,11 @@ struct FullTableView: View { results = Array(repeating: nil, count: 6) correctForms = [] 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 { currentVerb = nil currentTense = nil @@ -312,7 +316,11 @@ struct FullTableView: View { if allCorrect { sessionCorrect += 1 } 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) }) _ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults) } diff --git a/Conjuga/Conjuga/Views/Settings/SettingsView.swift b/Conjuga/Conjuga/Views/Settings/SettingsView.swift index f1c0006..c3751e4 100644 --- a/Conjuga/Conjuga/Views/Settings/SettingsView.swift +++ b/Conjuga/Conjuga/Views/Settings/SettingsView.swift @@ -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.") } + 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") { if let progress { LabeledContent("Total Reviewed", value: "\(progress.totalReviewed)") diff --git a/Conjuga/Conjuga/Views/Verbs/VerbDetailView.swift b/Conjuga/Conjuga/Views/Verbs/VerbDetailView.swift index 881b0ce..9eb6d61 100644 --- a/Conjuga/Conjuga/Views/Verbs/VerbDetailView.swift +++ b/Conjuga/Conjuga/Views/Verbs/VerbDetailView.swift @@ -5,6 +5,7 @@ import SwiftData struct VerbDetailView: View { @Environment(\.modelContext) private var modelContext @Environment(VerbExampleCache.self) private var exampleCache + @Environment(ReflexiveVerbStore.self) private var reflexiveStore @State private var speechService = SpeechService() let verb: Verb @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) } + private var reflexiveEntries: [ReflexiveVerb] { + reflexiveStore.entries(for: verb.infinitive) + } + var body: some View { List { Section { @@ -46,6 +51,10 @@ struct VerbDetailView: View { Text("Info") } + if !reflexiveEntries.isEmpty { + reflexiveSection + } + Section { Picker("Tense", selection: $selectedTense) { 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 @ViewBuilder @@ -198,4 +239,5 @@ struct VerbDetailView: View { } .modelContainer(for: [Verb.self, VerbForm.self], inMemory: true) .environment(VerbExampleCache()) + .environment(ReflexiveVerbStore()) } diff --git a/Conjuga/Conjuga/Views/Verbs/VerbListView.swift b/Conjuga/Conjuga/Views/Verbs/VerbListView.swift index 7401e80..ebcb7c8 100644 --- a/Conjuga/Conjuga/Views/Verbs/VerbListView.swift +++ b/Conjuga/Conjuga/Views/Verbs/VerbListView.swift @@ -22,11 +22,13 @@ enum IrregularityCategory: String, CaseIterable, Identifiable { struct VerbListView: View { @Environment(\.modelContext) private var modelContext + @Environment(ReflexiveVerbStore.self) private var reflexiveStore @State private var verbs: [Verb] = [] @State private var irregularityByVerbId: [Int: Set] = [:] @State private var searchText = "" @State private var selectedLevel: String? @State private var selectedIrregularity: IrregularityCategory? + @State private var reflexiveOnly: Bool = false @State private var selectedVerb: Verb? private var filteredVerbs: [Verb] { @@ -40,6 +42,9 @@ struct VerbListView: View { return category == .anyIrregular ? !cats.isEmpty : cats.contains(category) } } + if reflexiveOnly { + result = result.filter { reflexiveStore.isReflexive(baseInfinitive: $0.infinitive) } + } if !searchText.isEmpty { let query = searchText.lowercased() 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("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 { - selectedLevel != nil || selectedIrregularity != nil + selectedLevel != nil || selectedIrregularity != nil || reflexiveOnly } @ViewBuilder @@ -131,6 +144,11 @@ struct VerbListView: View { selectedIrregularity = nil } } + if reflexiveOnly { + filterChip(text: "Reflexive", systemImage: "arrow.triangle.2.circlepath") { + reflexiveOnly = false + } + } Spacer() Text("\(filteredVerbs.count)") .font(.caption.monospacedDigit()) diff --git a/Conjuga/Conjuga/reflexive_verbs.json b/Conjuga/Conjuga/reflexive_verbs.json new file mode 100644 index 0000000..4dbf5af --- /dev/null +++ b/Conjuga/Conjuga/reflexive_verbs.json @@ -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"} +] diff --git a/Conjuga/SharedModels/Sources/SharedModels/ReflexiveVerb.swift b/Conjuga/SharedModels/Sources/SharedModels/ReflexiveVerb.swift new file mode 100644 index 0000000..4582d99 --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/ReflexiveVerb.swift @@ -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 + } +} diff --git a/Conjuga/project.yml b/Conjuga/project.yml index 695a80e..ade1eaf 100644 --- a/Conjuga/project.yml +++ b/Conjuga/project.yml @@ -47,6 +47,8 @@ targets: buildPhase: resources - path: Conjuga/course_data.json buildPhase: resources + - path: Conjuga/reflexive_verbs.json + buildPhase: resources info: path: Conjuga/Info.plist properties: