Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d3accb2c0 | |||
| 3d8cbccc4e |
@@ -21,6 +21,10 @@ final class UserProgress {
|
||||
var enabledTensesBlob: String = ""
|
||||
var unlockedBadgesBlob: String = ""
|
||||
|
||||
// Multi-select level + irregularity filters (Issue #26).
|
||||
var selectedLevelsBlob: String = ""
|
||||
var enabledIrregularCategoriesBlob: String = ""
|
||||
|
||||
init() {}
|
||||
|
||||
var selectedVerbLevel: VerbLevel {
|
||||
@@ -44,6 +48,44 @@ final class UserProgress {
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels currently enabled for practice. Multi-select per Issue #26.
|
||||
/// Setting this also syncs `selectedLevel` to the highest-ranked selection so
|
||||
/// legacy single-level consumers (widget, AI scenarios, word-of-day) stay consistent.
|
||||
var selectedVerbLevels: Set<VerbLevel> {
|
||||
get {
|
||||
let raw = decodeStringArray(from: selectedLevelsBlob, fallback: [])
|
||||
let decoded = Set(raw.compactMap(VerbLevel.init(rawValue:)))
|
||||
if !decoded.isEmpty { return decoded }
|
||||
// Pre-migration users: treat the single selectedLevel as the set.
|
||||
if let legacy = VerbLevel(rawValue: selectedLevel) {
|
||||
return [legacy]
|
||||
}
|
||||
return []
|
||||
}
|
||||
set {
|
||||
let sorted = newValue.map(\.rawValue)
|
||||
selectedLevelsBlob = Self.encodeStringArray(sorted)
|
||||
selectedLevel = VerbLevel.highest(in: newValue)?.rawValue ?? VerbLevel.basic.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
/// The single representative level for callers that need one value
|
||||
/// (word-of-day widget, AI chat/story scenarios). Highest selected level.
|
||||
var primaryLevel: VerbLevel {
|
||||
VerbLevel.highest(in: selectedVerbLevels) ?? selectedVerbLevel
|
||||
}
|
||||
|
||||
var enabledIrregularCategories: Set<IrregularSpan.SpanCategory> {
|
||||
get {
|
||||
let raw = decodeStringArray(from: enabledIrregularCategoriesBlob, fallback: [])
|
||||
return Set(raw.compactMap(IrregularSpan.SpanCategory.init(rawValue:)))
|
||||
}
|
||||
set {
|
||||
let sorted = newValue.map(\.rawValue)
|
||||
enabledIrregularCategoriesBlob = Self.encodeStringArray(sorted)
|
||||
}
|
||||
}
|
||||
|
||||
func setTenseEnabled(_ tenseId: String, enabled: Bool) {
|
||||
var values = Set(enabledTenseIDs)
|
||||
if enabled {
|
||||
@@ -54,6 +96,26 @@ final class UserProgress {
|
||||
enabledTenseIDs = values.sorted()
|
||||
}
|
||||
|
||||
func setLevelEnabled(_ level: VerbLevel, enabled: Bool) {
|
||||
var values = selectedVerbLevels
|
||||
if enabled {
|
||||
values.insert(level)
|
||||
} else {
|
||||
values.remove(level)
|
||||
}
|
||||
selectedVerbLevels = values
|
||||
}
|
||||
|
||||
func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) {
|
||||
var values = enabledIrregularCategories
|
||||
if enabled {
|
||||
values.insert(category)
|
||||
} else {
|
||||
values.remove(category)
|
||||
}
|
||||
enabledIrregularCategories = values
|
||||
}
|
||||
|
||||
func unlockBadge(_ badgeId: String) {
|
||||
var values = Set(unlockedBadgeIDs)
|
||||
values.insert(badgeId)
|
||||
@@ -67,6 +129,9 @@ final class UserProgress {
|
||||
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
|
||||
unlockedBadgeIDs = unlockedBadges
|
||||
}
|
||||
if selectedLevelsBlob.isEmpty, let legacy = VerbLevel(rawValue: selectedLevel) {
|
||||
selectedVerbLevels = [legacy]
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
|
||||
@@ -86,4 +151,5 @@ final class UserProgress {
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,13 +4,18 @@ import SwiftData
|
||||
|
||||
struct PracticeSettings: Sendable {
|
||||
let selectedLevel: String
|
||||
let selectedLevels: Set<String>
|
||||
let enabledTenses: Set<String>
|
||||
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
let showVosotros: Bool
|
||||
|
||||
init(progress: UserProgress?) {
|
||||
let resolved = progress?.enabledTenseIDs ?? []
|
||||
let resolvedTenses = progress?.enabledTenseIDs ?? []
|
||||
let resolvedLevels = progress?.selectedVerbLevels ?? []
|
||||
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
|
||||
self.enabledTenses = Set(resolved)
|
||||
self.selectedLevels = Set(resolvedLevels.map(\.rawValue))
|
||||
self.enabledTenses = Set(resolvedTenses)
|
||||
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
|
||||
self.showVosotros = progress?.showVosotros ?? true
|
||||
}
|
||||
|
||||
@@ -79,7 +84,9 @@ struct PracticeSessionService {
|
||||
|
||||
func randomFullTablePrompt() -> FullTablePrompt? {
|
||||
let settings = settings()
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
// 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)
|
||||
guard !verbs.isEmpty else { return nil }
|
||||
|
||||
for _ in 0..<40 {
|
||||
@@ -157,7 +164,10 @@ struct PracticeSessionService {
|
||||
|
||||
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
)
|
||||
let now = Date()
|
||||
var descriptor = FetchDescriptor<ReviewCard>(
|
||||
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
|
||||
@@ -184,7 +194,10 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickWeakForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
)
|
||||
|
||||
let descriptor = FetchDescriptor<ReviewCard>(
|
||||
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
||||
@@ -206,7 +219,12 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||
// 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 typeRange: ClosedRange<Int>
|
||||
|
||||
switch filter {
|
||||
@@ -243,7 +261,10 @@ struct PracticeSessionService {
|
||||
private func pickCommonTenseForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let coreTenseIDs = TenseID.coreTenseIDs
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
let verbs = referenceStore.fetchVerbs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
)
|
||||
guard let verb = verbs.randomElement() else { return nil }
|
||||
|
||||
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
||||
@@ -256,7 +277,10 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickRandomForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
let verbs = referenceStore.fetchVerbs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
)
|
||||
guard let verb = verbs.randomElement() else { return nil }
|
||||
|
||||
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
||||
|
||||
@@ -27,6 +27,50 @@ struct ReferenceStore {
|
||||
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
|
||||
}
|
||||
|
||||
/// Union of data-levels for all selected user-facing levels.
|
||||
/// Empty input produces an empty result — callers decide how to handle that.
|
||||
func fetchVerbs(selectedLevels: Set<String>) -> [Verb] {
|
||||
guard !selectedLevels.isEmpty else { return [] }
|
||||
let ids = PracticeFilter.verbIDs(
|
||||
matchingLevels: selectedLevels,
|
||||
in: fetchVerbs().map { .init(id: $0.id, level: $0.level) }
|
||||
)
|
||||
return fetchVerbs().filter { ids.contains($0.id) }
|
||||
}
|
||||
|
||||
/// Practice verb pool intersecting selected levels with selected irregular-span categories.
|
||||
/// Delegates to `PracticeFilter` so the intersection logic is unit-tested
|
||||
/// in SharedModels without a ModelContainer (Issue #26).
|
||||
func allowedVerbIDs(
|
||||
selectedLevels: Set<String>,
|
||||
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
) -> Set<Int> {
|
||||
PracticeFilter.allowedVerbIDs(
|
||||
verbs: fetchVerbs().map { .init(id: $0.id, level: $0.level) },
|
||||
spans: allIrregularSlots(),
|
||||
selectedLevels: selectedLevels,
|
||||
irregularCategories: irregularCategories
|
||||
)
|
||||
}
|
||||
|
||||
/// Convenience: full Verb objects passing both filters.
|
||||
func fetchVerbs(
|
||||
selectedLevels: Set<String>,
|
||||
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
) -> [Verb] {
|
||||
let ids = allowedVerbIDs(
|
||||
selectedLevels: selectedLevels,
|
||||
irregularCategories: irregularCategories
|
||||
)
|
||||
return fetchVerbs().filter { ids.contains($0.id) }
|
||||
}
|
||||
|
||||
private func allIrregularSlots() -> [PracticeFilter.IrregularSlot] {
|
||||
let descriptor = FetchDescriptor<IrregularSpan>()
|
||||
let spans = (try? context.fetch(descriptor)) ?? []
|
||||
return spans.map { .init(verbId: $0.verbId, category: $0.category) }
|
||||
}
|
||||
|
||||
func fetchVerb(id: Int) -> Verb? {
|
||||
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
|
||||
@@ -128,7 +128,7 @@ struct OnboardingView: View {
|
||||
|
||||
private func completeOnboarding() {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
progress.selectedVerbLevel = selectedLevel
|
||||
progress.selectedVerbLevels = [selectedLevel]
|
||||
if progress.enabledTenseIDs.isEmpty {
|
||||
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import SharedModels
|
||||
struct LyricsReaderView: View {
|
||||
let song: SavedSong
|
||||
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@State private var selectedWord: LyricsWordLookup?
|
||||
@State private var lookupCache: [String: LyricsWordLookup] = [:]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
@@ -15,6 +19,10 @@ struct LyricsReaderView: View {
|
||||
}
|
||||
.navigationTitle(song.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $selectedWord) { word in
|
||||
LyricsWordDetailSheet(word: word)
|
||||
.presentationDetents([.height(260)])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -56,15 +64,6 @@ struct LyricsReaderView: View {
|
||||
let spanishLines = song.lyricsES.components(separatedBy: "\n")
|
||||
let englishLines = song.lyricsEN.components(separatedBy: "\n")
|
||||
let lineCount = max(spanishLines.count, englishLines.count)
|
||||
let _ = {
|
||||
print("[LyricsReader] ES lines: \(spanishLines.count), EN lines: \(englishLines.count), rendering: \(lineCount)")
|
||||
for i in 0..<min(15, lineCount) {
|
||||
let es = i < spanishLines.count ? spanishLines[i] : "(none)"
|
||||
let en = i < englishLines.count ? englishLines[i] : "(none)"
|
||||
print(" [\(i)] ES: \(es.isEmpty ? "(blank)" : es)")
|
||||
print(" EN: \(en.isEmpty ? "(blank)" : en)")
|
||||
}
|
||||
}()
|
||||
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(0..<lineCount, id: \.self) { index in
|
||||
@@ -78,8 +77,7 @@ struct LyricsReaderView: View {
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if !es.isEmpty {
|
||||
Text(es)
|
||||
.font(.body.weight(.medium))
|
||||
spanishLine(es)
|
||||
}
|
||||
if !en.isEmpty {
|
||||
Text(en)
|
||||
@@ -94,4 +92,183 @@ struct LyricsReaderView: View {
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func spanishLine(_ line: String) -> some View {
|
||||
let tokens = line.components(separatedBy: " ")
|
||||
return LyricsFlowLayout(spacing: 0) {
|
||||
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
|
||||
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
|
||||
selectedWord = word
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lookup
|
||||
|
||||
private func makeLookup(for rawToken: String) -> LyricsWordLookup? {
|
||||
let cleaned = rawToken.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
|
||||
if let cached = lookupCache[cleaned] { return cached }
|
||||
guard let entry = dictionary.lookup(cleaned) else { return nil }
|
||||
|
||||
let displayWord = rawToken
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
let tenseDisplay = entry.tenseId.flatMap { TenseInfo.find($0)?.english }
|
||||
|
||||
let lookup = LyricsWordLookup(
|
||||
word: displayWord.isEmpty ? entry.word : displayWord,
|
||||
baseForm: entry.baseForm,
|
||||
english: entry.english,
|
||||
partOfSpeech: entry.partOfSpeech,
|
||||
tenseDisplay: tenseDisplay,
|
||||
person: entry.person
|
||||
)
|
||||
lookupCache[cleaned] = lookup
|
||||
return lookup
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word Lookup Model
|
||||
|
||||
private struct LyricsWordLookup: Identifiable, Hashable {
|
||||
let word: String
|
||||
let baseForm: String
|
||||
let english: String
|
||||
let partOfSpeech: String
|
||||
let tenseDisplay: String?
|
||||
let person: String?
|
||||
|
||||
var id: String { word }
|
||||
}
|
||||
|
||||
// MARK: - Word View
|
||||
|
||||
private struct LyricsWordView: View {
|
||||
let token: String
|
||||
let lookup: LyricsWordLookup?
|
||||
let onLookup: (LyricsWordLookup) -> Void
|
||||
|
||||
var body: some View {
|
||||
Text(token + " ")
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
.underline(lookup != nil, color: .teal.opacity(0.35))
|
||||
.contentShape(Rectangle())
|
||||
.onLongPressGesture(minimumDuration: 0.35) {
|
||||
if let lookup {
|
||||
onLookup(lookup)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detail Sheet
|
||||
|
||||
private struct LyricsWordDetailSheet: View {
|
||||
let word: LyricsWordLookup
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text(word.word)
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
if !word.partOfSpeech.isEmpty {
|
||||
Text(word.partOfSpeech)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !word.baseForm.isEmpty && word.baseForm.lowercased() != word.word.lowercased() {
|
||||
detailRow(label: "Base form", value: word.baseForm, italic: true)
|
||||
}
|
||||
|
||||
if !word.english.isEmpty {
|
||||
detailRow(label: "English", value: word.english)
|
||||
}
|
||||
|
||||
if let tenseDisplay = word.tenseDisplay {
|
||||
let personSuffix = (word.person?.isEmpty == false) ? " · \(word.person!)" : ""
|
||||
detailRow(label: "Tense", value: tenseDisplay + personSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailRow(label: String, value: String, italic: Bool = false) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("\(label):")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 86, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.italic(italic)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow Layout
|
||||
|
||||
private struct LyricsFlowLayout: Layout {
|
||||
var spacing: CGFloat = 0
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var height: CGFloat = 0
|
||||
for row in rows { height += row.map { $0.height }.max() ?? 0 }
|
||||
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||
return CGSize(width: proposal.width ?? 0, height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var y = bounds.minY
|
||||
var idx = 0
|
||||
for row in rows {
|
||||
var x = bounds.minX
|
||||
let rh = row.map { $0.height }.max() ?? 0
|
||||
for size in row {
|
||||
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width
|
||||
idx += 1
|
||||
}
|
||||
y += rh + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||
let mw = proposal.width ?? .infinity
|
||||
var rows: [[CGSize]] = [[]]
|
||||
var cw: CGFloat = 0
|
||||
for sv in subviews {
|
||||
let s = sv.sizeThatFits(.unspecified)
|
||||
if cw + s.width > mw && !rows[rows.count - 1].isEmpty {
|
||||
rows.append([])
|
||||
cw = 0
|
||||
}
|
||||
rows[rows.count - 1].append(s)
|
||||
cw += s.width
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ struct SettingsView: View {
|
||||
@State private var dailyGoal: Double = 50
|
||||
@State private var showVosotros: Bool = true
|
||||
@State private var autoFillStem: Bool = false
|
||||
@State private var selectedLevel: VerbLevel = .basic
|
||||
|
||||
private let levels = VerbLevel.allCases
|
||||
private let irregularCategories: [IrregularSpan.SpanCategory] = [
|
||||
.spelling, .stemChange, .uniqueIrregular
|
||||
]
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
@@ -40,19 +42,26 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Level") {
|
||||
Picker("Current Level", selection: $selectedLevel) {
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Text(level.displayName).tag(level)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedLevel) { _, newValue in
|
||||
progress?.selectedVerbLevel = newValue
|
||||
saveProgress()
|
||||
Section {
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Toggle(level.displayName, isOn: Binding(
|
||||
get: {
|
||||
progress?.selectedVerbLevels.contains(level) ?? false
|
||||
},
|
||||
set: { enabled in
|
||||
guard let progress else { return }
|
||||
progress.setLevelEnabled(level, enabled: enabled)
|
||||
saveProgress()
|
||||
}
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Levels")
|
||||
} footer: {
|
||||
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
|
||||
}
|
||||
|
||||
Section("Tenses") {
|
||||
Section {
|
||||
ForEach(TenseInfo.all) { tense in
|
||||
Toggle(tense.english, isOn: Binding(
|
||||
get: {
|
||||
@@ -65,6 +74,27 @@ struct SettingsView: View {
|
||||
}
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Tenses")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(irregularCategories, id: \.self) { category in
|
||||
Toggle(category.rawValue, isOn: Binding(
|
||||
get: {
|
||||
progress?.enabledIrregularCategories.contains(category) ?? false
|
||||
},
|
||||
set: { enabled in
|
||||
guard let progress else { return }
|
||||
progress.setIrregularCategoryEnabled(category, enabled: enabled)
|
||||
saveProgress()
|
||||
}
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Irregular Types")
|
||||
} footer: {
|
||||
Text("Leave all off to include regular and irregular verbs. Enable any to restrict practice to those irregularity types.")
|
||||
}
|
||||
|
||||
Section("Stats") {
|
||||
@@ -96,7 +126,6 @@ struct SettingsView: View {
|
||||
dailyGoal = Double(resolved.dailyGoal)
|
||||
showVosotros = resolved.showVosotros
|
||||
autoFillStem = resolved.autoFillStem
|
||||
selectedLevel = resolved.selectedVerbLevel
|
||||
}
|
||||
|
||||
private func saveProgress() {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure practice-pool filtering (Issue #26).
|
||||
///
|
||||
/// Takes plain value snapshots of the verb + irregular-span data and computes
|
||||
/// the set of verb IDs eligible for practice under the user's selected filters.
|
||||
/// Deliberately decoupled from SwiftData so the same logic is directly testable
|
||||
/// without a ModelContainer.
|
||||
public enum PracticeFilter {
|
||||
|
||||
/// Minimal verb snapshot for filtering.
|
||||
public struct VerbSlot: Sendable, Hashable {
|
||||
public let id: Int
|
||||
public let level: String
|
||||
public init(id: Int, level: String) {
|
||||
self.id = id
|
||||
self.level = level
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal irregular-span snapshot for filtering.
|
||||
public struct IrregularSlot: Sendable, Hashable {
|
||||
public let verbId: Int
|
||||
public let category: IrregularSpan.SpanCategory
|
||||
public init(verbId: Int, category: IrregularSpan.SpanCategory) {
|
||||
self.verbId = verbId
|
||||
self.category = category
|
||||
}
|
||||
}
|
||||
|
||||
/// Union of `VerbLevelGroup.dataLevels(for:)` across every user-facing level.
|
||||
/// An empty input produces an empty result; callers decide the empty semantics.
|
||||
public static func dataLevels(forSelectedLevels levels: Set<String>) -> Set<String> {
|
||||
levels.reduce(into: Set<String>()) { acc, level in
|
||||
acc.formUnion(VerbLevelGroup.dataLevels(for: level))
|
||||
}
|
||||
}
|
||||
|
||||
/// Verb IDs whose `level` falls inside any of the selected level groups.
|
||||
public static func verbIDs(
|
||||
matchingLevels selectedLevels: Set<String>,
|
||||
in verbs: [VerbSlot]
|
||||
) -> Set<Int> {
|
||||
guard !selectedLevels.isEmpty else { return [] }
|
||||
let expanded = dataLevels(forSelectedLevels: selectedLevels)
|
||||
return Set(verbs.filter { expanded.contains($0.level) }.map(\.id))
|
||||
}
|
||||
|
||||
/// Verb IDs that have at least one irregular span in the requested categories.
|
||||
/// Returns an empty set when `categories` is empty — caller decides whether
|
||||
/// that means "no constraint" or "no matches".
|
||||
public static func verbIDs(
|
||||
matchingIrregularCategories categories: Set<IrregularSpan.SpanCategory>,
|
||||
in spans: [IrregularSlot]
|
||||
) -> Set<Int> {
|
||||
guard !categories.isEmpty else { return [] }
|
||||
var ids = Set<Int>()
|
||||
for slot in spans where categories.contains(slot.category) {
|
||||
ids.insert(slot.verbId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/// Practice pool: verbs at the selected levels, intersected with irregular
|
||||
/// categories when that filter is active.
|
||||
///
|
||||
/// Semantics (Issue #26):
|
||||
/// - `selectedLevels` empty → empty pool (literal).
|
||||
/// - `irregularCategories` empty → no irregular constraint (all verbs at level).
|
||||
public static func allowedVerbIDs(
|
||||
verbs: [VerbSlot],
|
||||
spans: [IrregularSlot],
|
||||
selectedLevels: Set<String>,
|
||||
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
) -> Set<Int> {
|
||||
let levelIDs = verbIDs(matchingLevels: selectedLevels, in: verbs)
|
||||
guard !irregularCategories.isEmpty else { return levelIDs }
|
||||
let irregularIDs = verbIDs(matchingIrregularCategories: irregularCategories, in: spans)
|
||||
return levelIDs.intersection(irregularIDs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
public extension VerbLevel {
|
||||
/// The highest-ranked `VerbLevel` in `set` per `allCases` ordering.
|
||||
/// Used when a single representative level is required (word-of-day
|
||||
/// widget, AI chat/story scenario generation).
|
||||
static func highest(in set: Set<VerbLevel>) -> VerbLevel? {
|
||||
allCases.last { set.contains($0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import Testing
|
||||
@testable import SharedModels
|
||||
|
||||
// Practice pool = selected levels ∩ selected tenses ∩ selected irregular types
|
||||
// (Issue #26). This suite covers the level + irregular intersection; tense
|
||||
// filtering is a separate concern handled at the review-card layer.
|
||||
|
||||
@Suite("PracticeFilter — level selection")
|
||||
struct PracticeFilterLevelTests {
|
||||
|
||||
@Test("single level expands to that group's data-levels")
|
||||
func singleLevelExpansion() {
|
||||
let expected = VerbLevelGroup.dataLevels(for: "elementary")
|
||||
#expect(PracticeFilter.dataLevels(forSelectedLevels: ["elementary"]) == expected)
|
||||
}
|
||||
|
||||
@Test("multi-level union merges every group's data-levels")
|
||||
func multiLevelUnion() {
|
||||
let union = PracticeFilter.dataLevels(forSelectedLevels: ["elementary", "intermediate"])
|
||||
#expect(union.isSuperset(of: VerbLevelGroup.dataLevels(for: "elementary")))
|
||||
#expect(union.isSuperset(of: VerbLevelGroup.dataLevels(for: "intermediate")))
|
||||
}
|
||||
|
||||
@Test("empty selected-levels produces empty data-levels")
|
||||
func emptyLevelsProducesEmpty() {
|
||||
#expect(PracticeFilter.dataLevels(forSelectedLevels: []).isEmpty)
|
||||
}
|
||||
|
||||
@Test("unknown level passes through to its raw value")
|
||||
func unknownLevelPassthrough() {
|
||||
// Preserves VerbLevelGroup's fallback contract.
|
||||
#expect(PracticeFilter.dataLevels(forSelectedLevels: ["custom"]) == ["custom"])
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PracticeFilter — verb IDs by level")
|
||||
struct PracticeFilterVerbLevelTests {
|
||||
|
||||
// Fixtures: four verbs spanning basic + elementary subgroups + intermediate.
|
||||
private let verbs: [PracticeFilter.VerbSlot] = [
|
||||
.init(id: 1, level: "basic"),
|
||||
.init(id: 2, level: "elementary"),
|
||||
.init(id: 3, level: "elementary_2"),
|
||||
.init(id: 4, level: "intermediate_1"),
|
||||
]
|
||||
|
||||
@Test("elementary selection matches elementary base and subgroup levels")
|
||||
func elementarySubgroupMatch() {
|
||||
let ids = PracticeFilter.verbIDs(matchingLevels: ["elementary"], in: verbs)
|
||||
#expect(ids == [2, 3])
|
||||
}
|
||||
|
||||
@Test("multi-level selection unions matching verb IDs")
|
||||
func multiLevelUnionIds() {
|
||||
let ids = PracticeFilter.verbIDs(matchingLevels: ["basic", "intermediate"], in: verbs)
|
||||
#expect(ids == [1, 4])
|
||||
}
|
||||
|
||||
@Test("empty level selection returns no verbs (literal semantics)")
|
||||
func emptySelectionReturnsEmpty() {
|
||||
let ids = PracticeFilter.verbIDs(matchingLevels: [], in: verbs)
|
||||
#expect(ids.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PracticeFilter — verb IDs by irregular category")
|
||||
struct PracticeFilterIrregularTests {
|
||||
|
||||
// Verb 10: regular (no spans).
|
||||
// Verb 20: one spelling-change span.
|
||||
// Verb 30: stem-change + unique-irregular spans.
|
||||
private let spans: [PracticeFilter.IrregularSlot] = [
|
||||
.init(verbId: 20, category: .spelling),
|
||||
.init(verbId: 30, category: .stemChange),
|
||||
.init(verbId: 30, category: .uniqueIrregular),
|
||||
]
|
||||
|
||||
@Test("empty category set returns empty — caller decides the semantics")
|
||||
func emptyCategoriesReturnsEmpty() {
|
||||
let ids = PracticeFilter.verbIDs(matchingIrregularCategories: [], in: spans)
|
||||
#expect(ids.isEmpty)
|
||||
}
|
||||
|
||||
@Test("single category picks matching verbs only")
|
||||
func singleCategoryMatch() {
|
||||
let ids = PracticeFilter.verbIDs(matchingIrregularCategories: [.spelling], in: spans)
|
||||
#expect(ids == [20])
|
||||
}
|
||||
|
||||
@Test("multiple categories union their matches")
|
||||
func multipleCategoriesUnion() {
|
||||
let ids = PracticeFilter.verbIDs(
|
||||
matchingIrregularCategories: [.spelling, .stemChange],
|
||||
in: spans
|
||||
)
|
||||
#expect(ids == [20, 30])
|
||||
}
|
||||
|
||||
@Test("a verb with multiple matching spans is returned once")
|
||||
func verbWithMultipleSpansDeduped() {
|
||||
let ids = PracticeFilter.verbIDs(
|
||||
matchingIrregularCategories: [.stemChange, .uniqueIrregular],
|
||||
in: spans
|
||||
)
|
||||
#expect(ids == [30])
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PracticeFilter — allowedVerbIDs (levels ∩ irregulars)")
|
||||
struct PracticeFilterIntersectionTests {
|
||||
|
||||
// Realistic fixture:
|
||||
// #1 — basic, regular
|
||||
// #2 — basic, spelling-change
|
||||
// #3 — elementary, spelling-change
|
||||
// #4 — elementary, stem-change
|
||||
// #5 — intermediate, unique-irregular
|
||||
private let verbs: [PracticeFilter.VerbSlot] = [
|
||||
.init(id: 1, level: "basic"),
|
||||
.init(id: 2, level: "basic"),
|
||||
.init(id: 3, level: "elementary"),
|
||||
.init(id: 4, level: "elementary_1"),
|
||||
.init(id: 5, level: "intermediate"),
|
||||
]
|
||||
private let spans: [PracticeFilter.IrregularSlot] = [
|
||||
.init(verbId: 2, category: .spelling),
|
||||
.init(verbId: 3, category: .spelling),
|
||||
.init(verbId: 4, category: .stemChange),
|
||||
.init(verbId: 5, category: .uniqueIrregular),
|
||||
]
|
||||
|
||||
@Test("no irregular filter keeps every verb at the selected level")
|
||||
func noIrregularConstraint() {
|
||||
let ids = PracticeFilter.allowedVerbIDs(
|
||||
verbs: verbs,
|
||||
spans: spans,
|
||||
selectedLevels: ["basic"],
|
||||
irregularCategories: []
|
||||
)
|
||||
#expect(ids == [1, 2])
|
||||
}
|
||||
|
||||
@Test("Issue #26 worked example: beginner + spelling-change → only #2")
|
||||
func issueWorkedExample() {
|
||||
let ids = PracticeFilter.allowedVerbIDs(
|
||||
verbs: verbs,
|
||||
spans: spans,
|
||||
selectedLevels: ["basic"],
|
||||
irregularCategories: [.spelling]
|
||||
)
|
||||
#expect(ids == [2])
|
||||
}
|
||||
|
||||
@Test("filter is an intersection, not a union: level-mismatched spans are excluded")
|
||||
func intersectionExcludesOtherLevels() {
|
||||
// Elementary has a spelling-change verb (#3). Selecting basic + spelling
|
||||
// must NOT leak #3 through the irregular filter alone.
|
||||
let ids = PracticeFilter.allowedVerbIDs(
|
||||
verbs: verbs,
|
||||
spans: spans,
|
||||
selectedLevels: ["basic"],
|
||||
irregularCategories: [.spelling]
|
||||
)
|
||||
#expect(!ids.contains(3))
|
||||
}
|
||||
|
||||
@Test("empty level selection produces empty pool regardless of irregular filter")
|
||||
func emptyLevelsLocksOutPractice() {
|
||||
let ids = PracticeFilter.allowedVerbIDs(
|
||||
verbs: verbs,
|
||||
spans: spans,
|
||||
selectedLevels: [],
|
||||
irregularCategories: [.spelling]
|
||||
)
|
||||
#expect(ids.isEmpty)
|
||||
}
|
||||
|
||||
@Test("multi-level + multi-category pulls every matching pair")
|
||||
func multiLevelMultiCategory() {
|
||||
let ids = PracticeFilter.allowedVerbIDs(
|
||||
verbs: verbs,
|
||||
spans: spans,
|
||||
selectedLevels: ["elementary", "intermediate"],
|
||||
irregularCategories: [.stemChange, .uniqueIrregular]
|
||||
)
|
||||
#expect(ids == [4, 5])
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("VerbLevel.highest")
|
||||
struct VerbLevelHighestTests {
|
||||
|
||||
@Test("returns the highest-ranked level in the set")
|
||||
func highestOfMany() {
|
||||
#expect(VerbLevel.highest(in: [.basic, .intermediate, .elementary]) == .intermediate)
|
||||
}
|
||||
|
||||
@Test("returns the sole element when set is a singleton")
|
||||
func singleton() {
|
||||
#expect(VerbLevel.highest(in: [.advanced]) == .advanced)
|
||||
}
|
||||
|
||||
@Test("returns nil for the empty set")
|
||||
func empty() {
|
||||
#expect(VerbLevel.highest(in: []) == nil)
|
||||
}
|
||||
|
||||
@Test("ranks expert above advanced above intermediate above elementary above basic")
|
||||
func fullRanking() {
|
||||
#expect(VerbLevel.highest(in: [.basic, .elementary, .intermediate, .advanced, .expert]) == .expert)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user