Files
Spanish/Conjuga/Conjuga/Views/Practice/FullTableView.swift
T
Trey T d0582c4ce7 Full Table — vary tense and verb family between consecutive prompts
randomFullTablePrompt now takes the previous prompt's tense and verb
ending and picks the next one to avoid an immediate repeat: when more
than one tense is selected the just-shown tense is excluded, and when
both -ar and -er/-ir verbs are available the next verb switches family.

Both constraints are best-effort — if honouring one would leave no
eligible fully-regular combo it is dropped, and the exhaustive
"anything eligible at all" guarantee is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:41:18 -05:00

393 lines
14 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
import PencilKit
/// Practice mode where user fills in all 6 person conjugations for a verb + tense.
struct FullTableView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
let speechService: SpeechService
@State private var currentVerb: Verb?
@State private var currentTense: TenseInfo?
@State private var correctForms: [VerbForm] = []
@State private var userAnswers: [String] = Array(repeating: "", count: 6)
@State private var results: [Bool?] = Array(repeating: nil, count: 6)
@State private var isChecked = false
@State private var showVosotros = true
@State private var autoFillStem = false
@State private var useHandwriting = false
@State private var sessionCount = 0
@State private var sessionCorrect = 0
@State private var noEligibleVerbs = false
// Handwriting state per field
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
@State private var activeHWField: Int?
@State private var isRecognizing = false
@FocusState private var focusedField: Int?
private let persons = TenseInfo.persons
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var personsToShow: [(index: Int, label: String)] {
persons.enumerated().compactMap { index, label in
if !showVosotros && index == 4 { return nil }
return (index, label)
}
}
/// Get the stem of the current verb (infinitive minus ending)
private var verbStem: String {
guard let verb = currentVerb else { return "" }
let inf = verb.infinitive.lowercased()
if inf.hasSuffix("ar") || inf.hasSuffix("er") || inf.hasSuffix("ir") {
return String(inf.dropLast(2))
}
if inf.hasSuffix("ír") {
return String(inf.dropLast(2))
}
return inf
}
var body: some View {
ScrollView {
if noEligibleVerbs {
emptyPoolError
} else {
VStack(spacing: 32) {
// Header
if let verb = currentVerb, let tense = currentTense {
headerSection(verb: verb, tense: tense)
}
// Input mode toggle
HStack {
Picker("Input", selection: $useHandwriting) {
Label("Keyboard", systemImage: "keyboard").tag(false)
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
// Input fields
inputSection
// Check / Next button
actionButton
// Score
if sessionCount > 0 {
scoreSection
}
}
.padding()
.adaptiveContainer()
}
}
.navigationTitle("Full Table")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
loadSettings()
loadNextVerb()
}
}
// MARK: - Empty pool error
private var emptyPoolError: some View {
VStack(spacing: 16) {
ContentUnavailableView(
"No regular verbs available",
systemImage: "exclamationmark.triangle",
description: Text(
"None of the selected tenses have any fully-regular verbs in the current settings. Enable more tenses, or turn off the Reflexive-only toggle in Settings."
)
)
}
.padding()
.adaptiveContainer()
}
// MARK: - Header
private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
VStack(spacing: 8) {
Text(verb.infinitive)
.font(.largeTitle.weight(.bold))
Button {
speechService.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2")
.font(.title3)
}
.tint(.secondary)
Text(verb.english)
.font(.title3)
.foregroundStyle(.secondary)
TensePill(tenseInfo: tense)
}
.padding(.top, 8)
}
// MARK: - Input Fields
private var inputSection: some View {
VStack(spacing: 16) {
ForEach(personsToShow, id: \.index) { index, label in
VStack(spacing: 4) {
HStack(spacing: 12) {
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
.frame(minWidth: 90, alignment: .trailing)
if useHandwriting {
// Handwriting canvas per field
ZStack(alignment: .trailing) {
VStack(spacing: 0) {
if autoFillStem {
Text(verbStem)
.font(.caption)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .leading)
}
HandwritingCanvas(drawing: $drawings[index])
.frame(height: 60)
.disabled(isChecked)
}
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(dividerColor(for: index), lineWidth: 1)
)
if let result = results[index] {
Image(systemName: result ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(result ? .green : .red)
.font(.title3)
.padding(.trailing, 8)
}
}
} else {
// Keyboard text field
ZStack(alignment: .trailing) {
TextField(autoFillStem ? verbStem : "", text: $userAnswers[index])
.font(.title3)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: index)
.disabled(isChecked)
.onSubmit { advanceFocus(from: index) }
.foregroundStyle(foregroundColor(for: index))
if let result = results[index] {
Image(systemName: result ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(result ? .green : .red)
.font(.title3)
}
}
Divider()
.frame(height: 1)
.overlay(dividerColor(for: index))
}
}
// Show correct answer if wrong
if isChecked, let result = results[index], !result {
HStack {
Spacer()
.frame(minWidth: 102)
Text(correctForms.first(where: { $0.personIndex == index })?.form ?? "")
.font(.callout.weight(.medium))
.foregroundStyle(.green)
}
}
}
}
}
.padding()
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
}
// MARK: - Action Button
private var actionButton: some View {
Button {
if isChecked {
loadNextVerb()
} else {
if useHandwriting {
recognizeAndCheck()
} else {
checkAnswers()
}
}
} label: {
HStack {
if isRecognizing {
ProgressView().tint(.white)
}
Text(isChecked ? "Next" : "Check")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(isChecked ? .blue : .orange)
.disabled(isRecognizing)
}
// MARK: - Score
private var scoreSection: some View {
HStack(spacing: 20) {
Label("\(sessionCount) reviewed", systemImage: "square.stack")
Label("\(sessionCorrect) perfect", systemImage: "checkmark.seal")
}
.font(.caption)
.foregroundStyle(.secondary)
}
// MARK: - Logic
private func loadNextVerb() {
isChecked = false
results = Array(repeating: nil, count: 6)
correctForms = []
drawings = Array(repeating: PKDrawing(), count: 6)
let service = PracticeSessionService(
localContext: modelContext,
cloudContext: cloudModelContext,
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
)
guard let prompt = service.randomFullTablePrompt(
previousTenseId: currentTense?.id,
previousEnding: currentVerb?.ending
) else {
// Genuinely no eligible (verb, tense) combo. Surface a clear error
// instead of a blank screen the previous behaviour silently
// rendered an empty header and inputs.
currentVerb = nil
currentTense = nil
userAnswers = Array(repeating: "", count: 6)
focusedField = nil
noEligibleVerbs = true
return
}
noEligibleVerbs = false
currentVerb = prompt.verb
currentTense = prompt.tenseInfo
correctForms = prompt.forms
// Auto-fill stems if enabled
if autoFillStem {
let stem = verbStem
userAnswers = Array(repeating: stem, count: 6)
} else {
userAnswers = Array(repeating: "", count: 6)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
if !useHandwriting {
focusedField = personsToShow.first?.index ?? 0
}
}
}
private func recognizeAndCheck() {
isRecognizing = true
Task {
for person in personsToShow {
let idx = person.index
let result = await HandwritingRecognizer.recognize(drawing: drawings[idx])
var recognized = result.text
// Prepend stem if auto-fill is on
if autoFillStem && !recognized.isEmpty {
recognized = verbStem + recognized
}
await MainActor.run {
userAnswers[idx] = recognized
}
}
await MainActor.run {
isRecognizing = false
checkAnswers()
}
}
}
private func checkAnswers() {
var allCorrect = true
for person in personsToShow {
let index = person.index
let userAnswer = userAnswers[index].trimmingCharacters(in: .whitespacesAndNewlines)
let correctForm = correctForms.first(where: { $0.personIndex == index })?.form ?? ""
let isCorrect = userAnswer.compare(correctForm, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
results[index] = isCorrect
if !isCorrect { allCorrect = false }
}
isChecked = true
sessionCount += 1
if allCorrect { sessionCorrect += 1 }
if let verb = currentVerb, let tense = currentTense {
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)
}
focusedField = nil
}
private func advanceFocus(from index: Int) {
let remaining = personsToShow.filter { $0.index > index }
if let next = remaining.first {
focusedField = next.index
} else {
focusedField = nil
if !isChecked { checkAnswers() }
}
}
private func loadSettings() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
showVosotros = progress.showVosotros
autoFillStem = progress.autoFillStem
}
private func foregroundColor(for index: Int) -> Color {
guard let result = results[index] else { return .primary }
return result ? .green : .red
}
private func dividerColor(for index: Int) -> Color {
guard let result = results[index] else { return .secondary.opacity(0.3) }
return result ? .green : .red
}
}
#Preview {
NavigationStack {
FullTableView(speechService: SpeechService())
}
.modelContainer(for: [Verb.self, VerbForm.self, ReviewCard.self, UserProgress.self], inMemory: true)
}