d0582c4ce7
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>
393 lines
14 KiB
Swift
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)
|
|
}
|