From 9cd159868a87f4b1fe6f01a1b1a8aa84834015cd Mon Sep 17 00:00:00 2001 From: shreeraman arunachalam karikalan Date: Sun, 19 Jul 2026 23:16:32 -0700 Subject: [PATCH 1/4] Restructure Train by Voice composer into a 3-step accordion Splits the flat replacement composer into Word -> Record -> Verify & Save steps with an extracted, unit-tested step-derivation model. - Extract pure step logic into DictionaryTrainingStepModel (SwiftUI-free) - Render composer as a 3-step accordion with per-step status glyphs - Reset hasReachedVerify latch on every teardown path (Try Again, capture removal, mode toggle) so Verify never dead-ends with Save disabled - Reset lastAnnouncedTrainingStep and announce any forward step edge (including word->verify) for correct VoiceOver cues - Make Record/Verify headers non-interactive while the word is empty - Move trainingReplacement onChange to the always-mounted accordion so programmatic word writes still reset progress - Add unit tests for step derivation, readiness predicates, and edges --- Fluid.xcodeproj/project.pbxproj | 4 + Sources/Fluid/UI/CustomDictionaryView.swift | 437 ++++++++++++++++-- .../UI/DictionaryTrainingStepModel.swift | 175 +++++++ .../DictionaryTrainingStepModelTests.swift | 315 +++++++++++++ 4 files changed, 905 insertions(+), 26 deletions(-) create mode 100644 Sources/Fluid/UI/DictionaryTrainingStepModel.swift create mode 100644 Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 2b135c8c..742d9624 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 7C9A71022F58B00000FB7CAF /* TranscribeCpp in Frameworks */ = {isa = PBXBuildFile; productRef = 7C9A71012F58B00000FB7CAF /* TranscribeCpp */; }; 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; + A62300000000000000000004 /* DictionaryTrainingStepModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000003 /* DictionaryTrainingStepModelTests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; }; @@ -54,6 +55,7 @@ 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = ""; }; C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineRetirementDrainTests.swift; sourceTree = ""; }; + A62300000000000000000003 /* DictionaryTrainingStepModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryTrainingStepModelTests.swift; sourceTree = ""; }; 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -132,6 +134,7 @@ 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, A62300000000000000000001 /* AudioBufferConverterTests.swift */, C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */, + A62300000000000000000003 /* DictionaryTrainingStepModelTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -291,6 +294,7 @@ 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */, C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */, + A62300000000000000000004 /* DictionaryTrainingStepModelTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index d03d4957..ae53210f 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -47,6 +47,10 @@ struct CustomDictionaryView: View { @State private var isAutomaticTrainingEnabled = false @State private var isTrainedReplacementButtonHovered = false @State private var isTrainedReplacementGlowExpanded = false + @State private var hasReachedVerifyStep = false + @State private var manualExpandedTrainingStep: DictionaryTrainingStep? + @State private var lastAnnouncedTrainingStep: DictionaryTrainingStep = .word + @FocusState private var isTrainingWordFieldFocused: Bool @State private var replacementConfirmation: ReplacementConfirmation? @State private var composerMode: DictionaryComposerMode = .train @State private var manualTriggerDraft = "" @@ -104,27 +108,29 @@ struct CustomDictionaryView: View { } private var trainingFinalOutputIsReady: Bool { - if self.activePronunciationMatching { - return !self.trainingAlreadyCorrectWithoutReplacement && - self.trainingPronunciationEnrollments.count >= CustomDictionaryTrainingMerge.readyCoveredCount - } - return !self.trainingAlreadyCorrectWithoutReplacement && - self.trainingOutputIsCovered && - self.consecutiveCoveredCaptures >= CustomDictionaryTrainingMerge.readyCoveredCount + DictionaryTrainingStepModel.finalOutputIsReady( + normalizedWord: self.normalizedTrainingReplacement, + consecutiveCoveredCaptures: self.consecutiveCoveredCaptures, + pronunciationEnrollmentCount: self.trainingPronunciationEnrollments.count, + lastTrainingOutput: self.lastTrainingOutput, + lastTrainingOutputIsCovered: self.lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: self.trainingVariants.isEmpty, + activePronunciationMatching: self.activePronunciationMatching, + readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount + ) } private var trainingAlreadyCorrectWithoutReplacement: Bool { - if self.activePronunciationMatching { - return self.trainingVariants.isEmpty && - !self.lastTrainingOutput.isEmpty && - self.lastTrainingOutput.caseInsensitiveCompare(self.normalizedTrainingReplacement) == .orderedSame && - self.trainingPronunciationEnrollments.count >= CustomDictionaryTrainingMerge.readyCoveredCount - } - return self.trainingVariants.isEmpty && - self.trainingOutputIsCovered && - !self.lastTrainingOutput.isEmpty && - self.lastTrainingOutput.caseInsensitiveCompare(self.normalizedTrainingReplacement) == .orderedSame && - self.consecutiveCoveredCaptures >= CustomDictionaryTrainingMerge.readyCoveredCount + DictionaryTrainingStepModel.alreadyCorrectWithoutReplacement( + normalizedWord: self.normalizedTrainingReplacement, + consecutiveCoveredCaptures: self.consecutiveCoveredCaptures, + pronunciationEnrollmentCount: self.trainingPronunciationEnrollments.count, + lastTrainingOutput: self.lastTrainingOutput, + lastTrainingOutputIsCovered: self.lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: self.trainingVariants.isEmpty, + activePronunciationMatching: self.activePronunciationMatching, + readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount + ) } private var trainingReadinessProgress: Int { @@ -145,6 +151,63 @@ struct CustomDictionaryView: View { return self.lastTrainingOutputIsCovered } + // MARK: - Train by Voice accordion + + private var isTrainingRecordingLocked: Bool { + self.isTrainingRecording || self.isTrainingStarting || self.isAutomaticTrainingEnabled + } + + private var isTrainingVerifyReady: Bool { + self.trainingFinalOutputIsReady || self.trainingAlreadyCorrectWithoutReplacement + } + + private var derivedTrainingStep: DictionaryTrainingStep { + DictionaryTrainingStepModel.derivedStep( + normalizedWord: self.normalizedTrainingReplacement, + consecutiveCoveredCaptures: self.consecutiveCoveredCaptures, + pronunciationEnrollmentCount: self.trainingPronunciationEnrollments.count, + lastTrainingOutput: self.lastTrainingOutput, + lastTrainingOutputIsCovered: self.lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: self.trainingVariants.isEmpty, + activePronunciationMatching: self.activePronunciationMatching, + readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount, + hasReachedVerify: self.hasReachedVerifyStep + ) + } + + private var expandedTrainingStep: DictionaryTrainingStep { + DictionaryTrainingStepModel.resolveExpandedStep( + derived: self.derivedTrainingStep, + manualOverride: self.manualExpandedTrainingStep, + isRecordingLocked: self.isTrainingRecordingLocked, + isWordFieldFocused: self.isTrainingWordFieldFocused + ) + } + + /// True when the user manually reopened step ① after already advancing past it + /// (word non-empty and some voice training progress exists). Used to show the + /// "editing restarts training" caption. + private var isReopeningTrainingWordStepAfterProgress: Bool { + self.manualExpandedTrainingStep == .word && + self.derivedTrainingStep != .word && + (self.trainingSampleCount > 0 || !self.trainingPronunciationEnrollments.isEmpty) + } + + /// A step header is tappable unless the recording lock pins `.record`, or the + /// word is still empty (Record/Verify have nothing to act on and would strand + /// the user on a disabled panel with no caption). + private func isTrainingStepInteractive(_ step: DictionaryTrainingStep) -> Bool { + if self.isTrainingRecordingLocked && step != .record { return false } + if step != .word && self.normalizedTrainingReplacement.isEmpty { return false } + return true + } + + private func selectTrainingStep(_ step: DictionaryTrainingStep) { + guard self.isTrainingStepInteractive(step) else { return } + self.manualExpandedTrainingStep = step + self.isTrainingWordFieldFocused = step == .word + } + private var trainingFinalOutputText: String { guard !self.lastTrainingOutput.isEmpty else { return "Record to check" } return self.trainingOutputIsCovered ? self.normalizedTrainingReplacement : self.lastTrainingOutput @@ -481,27 +544,177 @@ struct CustomDictionaryView: View { } private var trainReplacementComposer: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + self.trainingStepHeader(.word) + if self.expandedTrainingStep == .word { + self.trainingWordStepBody + } + + self.trainingStepHeader(.record) + if self.expandedTrainingStep == .record { + self.trainingRecordStepBody + } + + self.trainingStepHeader(.verify) + if self.expandedTrainingStep == .verify { + self.trainingVerifyStepBody + } + } + .animation(self.reduceMotion ? nil : .easeInOut(duration: 0.22), value: self.expandedTrainingStep) + .task { + await DictionaryTrainingEndpointMonitor.shared.prepare() + } + // Attached to the always-mounted accordion (not the conditionally-rendered + // word TextField) so a programmatic write to trainingReplacement while step ① + // is collapsed still resets progress/latch/coverage. + .onChange(of: self.trainingReplacement) { oldValue, newValue in + self.handleTrainingReplacementChange(oldValue: oldValue, newValue: newValue) + } + .onChange(of: self.derivedTrainingStep) { _, _ in + self.manualExpandedTrainingStep = nil + } + .onChange(of: self.expandedTrainingStep) { oldStep, newStep in + self.announceTrainingStepEdgeIfNeeded(from: oldStep, to: newStep) + } + .onChange(of: self.isTrainingRecordingLocked) { _, isLocked in + if isLocked { + self.manualExpandedTrainingStep = nil + } + } + .onChange(of: self.isTrainingVerifyReady) { _, isReady in + if isReady { + self.hasReachedVerifyStep = true + } + } + } + + private func announceTrainingStepEdgeIfNeeded(from oldStep: DictionaryTrainingStep, to newStep: DictionaryTrainingStep) { + guard oldStep != newStep, self.lastAnnouncedTrainingStep != newStep else { return } + // Any advance in step order is a forward edge — including the word→verify jump + // when captures are already sufficient (advanceFromWordStep resolving to .verify). + guard newStep.rawValue > oldStep.rawValue else { return } + self.lastAnnouncedTrainingStep = newStep + AccessibilityNotification.Announcement(DictionaryTrainingCopy.stepAnnouncement(for: newStep)).post() + } + + @ViewBuilder + private func trainingStepHeader(_ step: DictionaryTrainingStep) -> some View { + let isInteractive = self.isTrainingStepInteractive(step) + DictionaryTrainingStepHeaderView( + step: step, + status: self.trainingStepStatus(step), + title: DictionaryTrainingCopy.stepTitle(step), + subtitle: self.trainingStepSubtitle(step), + isExpanded: self.expandedTrainingStep == step, + isInteractive: isInteractive + ) { + self.selectTrainingStep(step) + } + } + + private func trainingStepStatus(_ step: DictionaryTrainingStep) -> DictionaryTrainingStepHeaderView.Status { + if step.rawValue < self.derivedTrainingStep.rawValue { + return .complete + } + if step == self.derivedTrainingStep { + return .current + } + return .upcoming + } + + private func trainingStepSubtitle(_ step: DictionaryTrainingStep) -> String { + switch step { + case .word: + return DictionaryTrainingCopy.wordStepSubtitle( + normalizedWord: self.normalizedTrainingReplacement, + isPastWordStep: self.derivedTrainingStep != .word + ) + case .record: + let isPreloaded = self.trainingSampleCount == 0 && !self.trainingVariants.isEmpty + return DictionaryTrainingCopy.recordStepSubtitle( + derivedStep: self.derivedTrainingStep, + preloadedCaptureCount: isPreloaded ? self.trainingVariants.count : nil, + progress: self.trainingReadinessProgress, + total: CustomDictionaryTrainingMerge.readyCoveredCount + ) + case .verify: + return DictionaryTrainingCopy.verifyStepSubtitle(isReady: self.isTrainingVerifyReady) + } + } + + @ViewBuilder + private var trainingWordStepBody: some View { VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { TextField("Type the correct text, e.g. FluidVoice", text: self.$trainingReplacement) .dictionaryInputChrome() .disabled(self.isTrainingRecording || self.isTrainingProcessing) - .onChange(of: self.trainingReplacement) { oldValue, newValue in - self.handleTrainingReplacementChange(oldValue: oldValue, newValue: newValue) + .focused(self.$isTrainingWordFieldFocused) + .onSubmit { + self.advanceFromWordStep() } + if self.isReopeningTrainingWordStepAfterProgress { + Label(DictionaryTrainingCopy.editingWordRestartsTrainingCaption, systemImage: "exclamationmark.circle") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.warning) + } + } + .padding(.leading, self.trainingStepBodyLeadingInset) + } + + /// Commits the typed word and advances past step ①. Blurring the field drops + /// the focus pin so `expandedTrainingStep` resolves to the derived step + /// (`.record`, or `.verify` when captures are already sufficient). No-op while + /// the word is empty so Tab/Return can't strand the user on an empty Record step. + private func advanceFromWordStep() { + guard !self.normalizedTrainingReplacement.isEmpty else { return } + self.manualExpandedTrainingStep = nil + self.isTrainingWordFieldFocused = false + } + + @ViewBuilder + private var trainingRecordStepBody: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { self.voiceMatchingSettingsRow self.trainingRecorderPanel - self.trainingFinalOutputPanel + if let caption = self.trainingStartDisabledCaption { + Label(caption, systemImage: "info.circle") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) + } if !self.trainingVariants.isEmpty { self.trainingHeardSection } self.trainingFooter + } + .padding(.leading, self.trainingStepBodyLeadingInset) + } - Spacer(minLength: 0) + /// Copy for the three Start-disabled causes reachable in step ②. Word-empty is + /// excluded by construction: the Record header is non-interactive while the word + /// is empty (isTrainingStepInteractive), and the derived step is `.word` anyway, + /// so this body never renders without a word. + private var trainingStartDisabledCaption: String? { + if self.asr.isRunning && !self.isTrainingRecording && !self.isTrainingStarting && !self.isAutomaticTrainingEnabled { + return DictionaryTrainingCopy.dictationRunningCaption + } + if self.isTrainingProcessing { + return DictionaryTrainingCopy.trainingProcessingCaption + } + if self.trainingSampleCount >= CustomDictionaryTrainingMerge.maxSamples { + return DictionaryTrainingCopy.maxSamplesReachedCaption + } + return nil + } + + @ViewBuilder + private var trainingVerifyStepBody: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + self.trainingFinalOutputPanel Button { Task { await self.addTrainedReplacement() } @@ -535,11 +748,11 @@ struct CustomDictionaryView: View { self.updateTrainedReplacementGlow() } } - .task { - await DictionaryTrainingEndpointMonitor.shared.prepare() - } + .padding(.leading, self.trainingStepBodyLeadingInset) } + private var trainingStepBodyLeadingInset: CGFloat { 28 } + private var trainedReplacementButtonReadyOutline: some View { RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) .stroke( @@ -1877,6 +2090,9 @@ struct CustomDictionaryView: View { self.consecutiveCoveredCaptures = 0 self.trainingStatusMessage = "" self.trainingHasError = false + // Tearing down verification progress must drop the Verify latch, otherwise + // the accordion stays stuck on step ③ (Save disabled) after Try Again. + self.hasReachedVerifyStep = false } private func addTrainingVariant(from transcript: String) { @@ -1995,6 +2211,11 @@ struct CustomDictionaryView: View { private func removeTrainingVariant(_ variant: String) { self.trainingVariants.removeAll { $0 == variant } self.refreshLastTrainingCoverage() + // Removing a capture may drop us below ready; clear the latch so the derived + // step re-computes from live coverage instead of pinning Verify. If the + // remaining captures are still sufficient, `finalOutputIsReady` re-derives + // `.verify` on its own. + self.hasReachedVerifyStep = false } private func refreshLastTrainingCoverage() { @@ -2034,6 +2255,9 @@ struct CustomDictionaryView: View { self.isTrainingRecording = false self.trainingStopRequestedDuringStart = false self.isTrainingProcessing = false + self.hasReachedVerifyStep = false + self.manualExpandedTrainingStep = nil + self.lastAnnouncedTrainingStep = .word } private func handleTrainingReplacementChange(oldValue: String, newValue: String) { @@ -2048,6 +2272,8 @@ struct CustomDictionaryView: View { self.lastTrainingOutputIsCovered = false self.consecutiveCoveredCaptures = 0 self.isTrainingActive = false + self.hasReachedVerifyStep = false + self.lastAnnouncedTrainingStep = .word if newKey.isEmpty { self.trainingStatusMessage = "Type the correct text." } else if self.trainingVariants.isEmpty { @@ -2285,7 +2511,10 @@ private extension CustomDictionaryView { DictionaryTrainingEndpointMonitor.shared.stop() self.trainingVariants = self.existingTrainingVariants(for: self.trainingReplacement) self.trainingPronunciationEnrollments = [] - self.resetTrainingVerificationAttempts() + self.resetTrainingVerificationAttempts() // also clears hasReachedVerifyStep + // Mode toggle resets progress; drop any manual step override so the accordion + // follows the freshly-derived step rather than a stale expanded panel. + self.manualExpandedTrainingStep = nil self.trainingStatusMessage = self.normalizedTrainingReplacement.isEmpty ? "Type the correct text." : "" @@ -2536,6 +2765,56 @@ private enum DictionaryTrainingCopy { ? "Say \(target) 3 times to unlock Add Replacement." : "Keep trying until FluidVoice gets \(target) right 3 times in a row." } + + // MARK: - Train by Voice accordion + + static func stepTitle(_ step: DictionaryTrainingStep) -> String { + switch step { + case .word: return "Word" + case .record: return "Record" + case .verify: return "Verify & Save" + } + } + + static func stepAnnouncement(for step: DictionaryTrainingStep) -> String { + switch step { + case .word: return "Step 1, Word." + case .record: return "Step 2, Record." + case .verify: return "Step 3, Verify and Save." + } + } + + static func wordStepSubtitle(normalizedWord: String, isPastWordStep: Bool) -> String { + isPastWordStep ? normalizedWord : "Type the word to teach" + } + + static func recordStepSubtitle( + derivedStep: DictionaryTrainingStep, + preloadedCaptureCount: Int?, + progress: Int, + total: Int + ) -> String { + switch derivedStep { + case .word: + return "Waiting for word…" + case .verify: + return "✓ Recognized \(total)/\(total)" + case .record: + if let preloadedCaptureCount { + return "Loaded \(preloadedCaptureCount) saved \(preloadedCaptureCount == 1 ? "capture" : "captures")" + } + return "Recorded \(progress)/\(total) — keep going" + } + } + + static func verifyStepSubtitle(isReady: Bool) -> String { + isReady ? "Ready to save" : "—" + } + + static let editingWordRestartsTrainingCaption = "Editing the word restarts voice training." + static let dictationRunningCaption = "Dictation is running — stop dictating to train." + static let trainingProcessingCaption = "Processing…" + static let maxSamplesReachedCaption = "Max samples reached — press Try Again or Clear." } private enum DictionaryComposerMode: CaseIterable, Identifiable { @@ -2637,6 +2916,112 @@ private struct DictionaryComposerModeTab: View { } } +private struct DictionaryTrainingStepHeaderView: View { + enum Status { + case upcoming + case current + case complete + } + + let step: DictionaryTrainingStep + let status: Status + let title: String + let subtitle: String + let isExpanded: Bool + let isInteractive: Bool + let action: () -> Void + + @Environment(\.theme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isHovered = false + + var body: some View { + Button(action: self.action) { + HStack(alignment: .center, spacing: self.theme.metrics.spacing.md) { + self.statusGlyph + + VStack(alignment: .leading, spacing: 1) { + Text("\(self.step.rawValue + 1). \(self.title)") + .font(self.theme.typography.bodySmallStrong) + .foregroundStyle(self.theme.palette.primaryText) + + Text(self.subtitle) + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) + .lineLimit(1) + } + + Spacer(minLength: self.theme.metrics.spacing.sm) + + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(self.theme.palette.tertiaryText) + .rotationEffect(.degrees(self.isExpanded ? 90 : 0)) + } + .padding(.horizontal, self.theme.metrics.spacing.md) + .padding(.vertical, self.theme.metrics.spacing.sm) + .background( + RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) + .fill( + self.isExpanded + ? self.theme.palette.contentBackground.opacity(0.55) + : (self.isHovered + ? self.theme.palette.contentBackground.opacity(0.32) + : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(self.isExpanded ? 0.28 : 0), lineWidth: 1) + ) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!self.isInteractive) + .opacity(self.isInteractive ? 1 : 0.55) + .onHover { hovering in + guard self.isInteractive else { return } + guard !self.reduceMotion else { + self.isHovered = hovering + return + } + withAnimation(.easeOut(duration: 0.14)) { + self.isHovered = hovering + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Step \(self.step.rawValue + 1), \(self.title), \(self.statusAccessibilityDescription)") + .accessibilityValue(self.subtitle) + .accessibilityAddTraits(self.isExpanded ? .isSelected : []) + } + + private var statusAccessibilityDescription: String { + switch self.status { + case .upcoming: return "not started" + case .current: return "in progress" + case .complete: return "complete" + } + } + + @ViewBuilder + private var statusGlyph: some View { + switch self.status { + case .upcoming: + Circle() + .stroke(self.theme.palette.cardBorder.opacity(0.6), lineWidth: 1.5) + .frame(width: 18, height: 18) + case .current: + Circle() + .fill(self.theme.palette.accent) + .frame(width: 18, height: 18) + case .complete: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(self.theme.palette.success) + } + } +} + enum CustomDictionaryManualEntry { static func normalizedTrigger(_ text: String) -> String? { let trigger = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() diff --git a/Sources/Fluid/UI/DictionaryTrainingStepModel.swift b/Sources/Fluid/UI/DictionaryTrainingStepModel.swift new file mode 100644 index 00000000..799be11f --- /dev/null +++ b/Sources/Fluid/UI/DictionaryTrainingStepModel.swift @@ -0,0 +1,175 @@ +// +// DictionaryTrainingStepModel.swift +// fluid +// +// Pure step-derivation and expansion-resolution logic for the "Train by Voice" +// accordion composer in CustomDictionaryView. Kept free of SwiftUI/@State so it +// can be unit tested directly. +// + +import Foundation + +/// The three always-visible steps of the "Train by Voice" accordion composer. +enum DictionaryTrainingStep: Int, CaseIterable, Equatable { + case word + case record + case verify +} + +enum DictionaryTrainingStepModel { + /// Replicates `trainingOutputIsCovered` (CustomDictionaryView.swift ~141-146). + private static func isOutputCovered( + lastTrainingOutputIsCovered: Bool, + trainingVariantsIsEmpty: Bool, + pronunciationEnrollmentCount: Int, + activePronunciationMatching: Bool + ) -> Bool { + if activePronunciationMatching { + return pronunciationEnrollmentCount > 0 + } + return lastTrainingOutputIsCovered + } + + /// Single source of truth for `trainingAlreadyCorrectWithoutReplacement`; + /// CustomDictionaryView delegates to this. + static func alreadyCorrectWithoutReplacement( + normalizedWord: String, + consecutiveCoveredCaptures: Int, + pronunciationEnrollmentCount: Int, + lastTrainingOutput: String, + lastTrainingOutputIsCovered: Bool, + trainingVariantsIsEmpty: Bool, + activePronunciationMatching: Bool, + readyCoveredCount: Int + ) -> Bool { + if activePronunciationMatching { + return trainingVariantsIsEmpty && + !lastTrainingOutput.isEmpty && + lastTrainingOutput.caseInsensitiveCompare(normalizedWord) == .orderedSame && + pronunciationEnrollmentCount >= readyCoveredCount + } + + let outputIsCovered = self.isOutputCovered( + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + activePronunciationMatching: activePronunciationMatching + ) + return trainingVariantsIsEmpty && + outputIsCovered && + !lastTrainingOutput.isEmpty && + lastTrainingOutput.caseInsensitiveCompare(normalizedWord) == .orderedSame && + consecutiveCoveredCaptures >= readyCoveredCount + } + + /// Single source of truth for `trainingFinalOutputIsReady`; + /// CustomDictionaryView delegates to this. + static func finalOutputIsReady( + normalizedWord: String, + consecutiveCoveredCaptures: Int, + pronunciationEnrollmentCount: Int, + lastTrainingOutput: String, + lastTrainingOutputIsCovered: Bool, + trainingVariantsIsEmpty: Bool, + activePronunciationMatching: Bool, + readyCoveredCount: Int + ) -> Bool { + let alreadyCorrect = self.alreadyCorrectWithoutReplacement( + normalizedWord: normalizedWord, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching, + readyCoveredCount: readyCoveredCount + ) + + if activePronunciationMatching { + return !alreadyCorrect && pronunciationEnrollmentCount >= readyCoveredCount + } + + let outputIsCovered = self.isOutputCovered( + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + activePronunciationMatching: activePronunciationMatching + ) + return !alreadyCorrect && outputIsCovered && consecutiveCoveredCaptures >= readyCoveredCount + } + + /// Pure derivation of which step the composer logically represents right now, + /// from primitive training state (not pre-derived booleans). + /// + /// - `.word` if the word is empty. + /// - `.verify` if the capture is ready, already-correct, or the verify lock + /// (`hasReachedVerify`) has been latched (prevents a post-ready missed + /// capture from snapping the accordion back to `.record`). + /// - `.record` otherwise. + static func derivedStep( + normalizedWord: String, + consecutiveCoveredCaptures: Int, + pronunciationEnrollmentCount: Int, + lastTrainingOutput: String, + lastTrainingOutputIsCovered: Bool, + trainingVariantsIsEmpty: Bool, + activePronunciationMatching: Bool, + readyCoveredCount: Int, + hasReachedVerify: Bool + ) -> DictionaryTrainingStep { + guard !normalizedWord.isEmpty else { return .word } + + let ready = self.finalOutputIsReady( + normalizedWord: normalizedWord, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching, + readyCoveredCount: readyCoveredCount + ) + let alreadyCorrect = self.alreadyCorrectWithoutReplacement( + normalizedWord: normalizedWord, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching, + readyCoveredCount: readyCoveredCount + ) + + if ready || alreadyCorrect || hasReachedVerify { + return .verify + } + return .record + } + + /// Pure resolution of which step's body should be expanded, given the derived + /// step and the accordion's interaction state. Priority order (highest first): + /// + /// 1. Recording lock (`isRecordingLocked`) always wins: `.record` is expanded, + /// no matter what the derived step or manual override say. + /// 2. Word-field focus always wins next: `.word` stays expanded so typing is + /// never interrupted. + /// 3. A manual header tap (`manualOverride`) wins over the derived step. + /// 4. Otherwise, the derived step is expanded. + static func resolveExpandedStep( + derived: DictionaryTrainingStep, + manualOverride: DictionaryTrainingStep?, + isRecordingLocked: Bool, + isWordFieldFocused: Bool + ) -> DictionaryTrainingStep { + if isRecordingLocked { + return .record + } + if isWordFieldFocused { + return .word + } + if let manualOverride { + return manualOverride + } + return derived + } +} diff --git a/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift b/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift new file mode 100644 index 00000000..1f5f7c07 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift @@ -0,0 +1,315 @@ +@testable import FluidVoice_Debug +import XCTest + +final class DictionaryTrainingStepModelTests: XCTestCase { + private let readyCoveredCount = 3 + + private func derived( + word: String = "FluidVoice", + consecutiveCoveredCaptures: Int = 0, + pronunciationEnrollmentCount: Int = 0, + lastTrainingOutput: String = "", + lastTrainingOutputIsCovered: Bool = false, + trainingVariantsIsEmpty: Bool = true, + activePronunciationMatching: Bool = false, + hasReachedVerify: Bool = false + ) -> DictionaryTrainingStep { + DictionaryTrainingStepModel.derivedStep( + normalizedWord: word, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching, + readyCoveredCount: self.readyCoveredCount, + hasReachedVerify: hasReachedVerify + ) + } + + // MARK: - derivedStep + + func testEmptyWordDerivesWordStep() { + // normalizedWord is expected pre-trimmed by the caller (matches + // CustomDictionaryView.normalizedTrainingReplacement), so only "" counts as empty. + XCTAssertEqual(self.derived(word: ""), .word) + } + + func testNonEmptyWordWithNoProgressDerivesRecordStep() { + XCTAssertEqual(self.derived(word: "FluidVoice"), .record) + } + + func testReadyAfterThreeConsecutiveCoveredCapturesDerivesVerifyStep() { + let step = self.derived( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + ) + XCTAssertEqual(step, .verify) + } + + func testAlmostReadyStaysOnRecordStep() { + let step = self.derived( + consecutiveCoveredCaptures: 2, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + ) + XCTAssertEqual(step, .record) + } + + func testAlreadyCorrectWithoutReplacementDerivesVerifyStep() { + // No captured variants, output already matches the word 3x in a row. + let step = self.derived( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: true + ) + XCTAssertEqual(step, .verify) + } + + func testPronunciationMatchingBranchUsesEnrollmentCountNotConsecutiveCaptures() { + // Consecutive captures is 0 (irrelevant in this branch); enrollment count drives readiness. + let notReady = self.derived( + consecutiveCoveredCaptures: 0, + pronunciationEnrollmentCount: 2, + trainingVariantsIsEmpty: false, + activePronunciationMatching: true + ) + XCTAssertEqual(notReady, .record) + + let ready = self.derived( + consecutiveCoveredCaptures: 0, + pronunciationEnrollmentCount: 3, + trainingVariantsIsEmpty: false, + activePronunciationMatching: true + ) + XCTAssertEqual(ready, .verify) + } + + func testVerifyLockSurvivesPostReadyMissedCapture() { + // consecutiveCoveredCaptures reset to 0 (a miss after being ready), but the + // verify lock is latched — must NOT snap back to .record. + let step = self.derived( + consecutiveCoveredCaptures: 0, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: false, + trainingVariantsIsEmpty: false, + hasReachedVerify: true + ) + XCTAssertEqual(step, .verify) + } + + func testPreloadedVariantsStateDerivesRecordStepNotVerify() { + // Preload: chips exist (trainingVariantsIsEmpty == false) but nothing recorded + // this session yet (consecutiveCoveredCaptures == 0, no lastTrainingOutput). + let step = self.derived( + consecutiveCoveredCaptures: 0, + lastTrainingOutput: "", + lastTrainingOutputIsCovered: false, + trainingVariantsIsEmpty: false + ) + XCTAssertEqual(step, .record) + } + + func testEmptyWordGuardOutranksVerifyLatch() { + // Word cleared after reaching Verify: the empty-word guard must win over the + // latch, dropping back to .word. Load-bearing for the word-edit reset flow. + XCTAssertEqual(self.derived(word: "", hasReachedVerify: true), .word) + } + + func testCoveredCapturesAreCaseInsensitiveAgainstWord() { + // lastTrainingOutput differs only in case from the word — must still count as + // an already-correct/ready match. + let step = self.derived( + word: "FluidVoice", + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "fluidvoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: true + ) + XCTAssertEqual(step, .verify) + } + + // MARK: - finalOutputIsReady / alreadyCorrectWithoutReplacement + + private func finalReady( + word: String = "FluidVoice", + consecutiveCoveredCaptures: Int = 0, + pronunciationEnrollmentCount: Int = 0, + lastTrainingOutput: String = "", + lastTrainingOutputIsCovered: Bool = false, + trainingVariantsIsEmpty: Bool = true, + activePronunciationMatching: Bool = false + ) -> Bool { + DictionaryTrainingStepModel.finalOutputIsReady( + normalizedWord: word, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching, + readyCoveredCount: self.readyCoveredCount + ) + } + + private func alreadyCorrect( + word: String = "FluidVoice", + consecutiveCoveredCaptures: Int = 0, + pronunciationEnrollmentCount: Int = 0, + lastTrainingOutput: String = "", + lastTrainingOutputIsCovered: Bool = false, + trainingVariantsIsEmpty: Bool = true, + activePronunciationMatching: Bool = false + ) -> Bool { + DictionaryTrainingStepModel.alreadyCorrectWithoutReplacement( + normalizedWord: word, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching, + readyCoveredCount: self.readyCoveredCount + ) + } + + func testAlreadyCorrectRequiresNoCapturedVariants() { + // Same covered output 3x, but variants still present → not "already correct" + // (there is something to save), so it's ready-to-save instead. + XCTAssertFalse(self.alreadyCorrect( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + )) + XCTAssertTrue(self.finalReady( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "FluidVoice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + )) + } + + func testAlreadyCorrectImpliesFinalOutputNotReady() { + // The Save-disabled invariant: when nothing needs saving, final output is not + // "ready" (there is no replacement to add). + let args: (Int, String, Bool, Bool) = (3, "FluidVoice", true, true) + XCTAssertTrue(self.alreadyCorrect( + consecutiveCoveredCaptures: args.0, + lastTrainingOutput: args.1, + lastTrainingOutputIsCovered: args.2, + trainingVariantsIsEmpty: args.3 + )) + XCTAssertFalse(self.finalReady( + consecutiveCoveredCaptures: args.0, + lastTrainingOutput: args.1, + lastTrainingOutputIsCovered: args.2, + trainingVariantsIsEmpty: args.3 + )) + } + + func testFinalReadyForCoveredNonMatchingOutput() { + // Covered by dictionary but output != word (a real replacement to save): + // ready must be true, alreadyCorrect false. + XCTAssertTrue(self.finalReady( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "fluid voice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + )) + XCTAssertFalse(self.alreadyCorrect( + consecutiveCoveredCaptures: 3, + lastTrainingOutput: "fluid voice", + lastTrainingOutputIsCovered: true, + trainingVariantsIsEmpty: false + )) + } + + func testPronunciationEnrollmentBoundary() { + // readyCoveredCount - 1 enrollments is not ready; == readyCoveredCount is. + XCTAssertFalse(self.finalReady( + pronunciationEnrollmentCount: self.readyCoveredCount - 1, + trainingVariantsIsEmpty: false, + activePronunciationMatching: true + )) + XCTAssertTrue(self.finalReady( + pronunciationEnrollmentCount: self.readyCoveredCount, + trainingVariantsIsEmpty: false, + activePronunciationMatching: true + )) + } + + func testPronunciationAlreadyCorrectWithEnoughEnrollments() { + // No variants to save, output matches word, enrollments sufficient → already + // correct, and therefore not ready-to-save. + XCTAssertTrue(self.alreadyCorrect( + pronunciationEnrollmentCount: self.readyCoveredCount, + lastTrainingOutput: "FluidVoice", + trainingVariantsIsEmpty: true, + activePronunciationMatching: true + )) + XCTAssertFalse(self.finalReady( + pronunciationEnrollmentCount: self.readyCoveredCount, + lastTrainingOutput: "FluidVoice", + trainingVariantsIsEmpty: true, + activePronunciationMatching: true + )) + } + + // MARK: - resolveExpandedStep + + func testRecordingLockOverridesManualOverrideAndDerivedStep() { + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: .verify, + manualOverride: .word, + isRecordingLocked: true, + isWordFieldFocused: false + ) + XCTAssertEqual(resolved, .record) + } + + func testWordFieldFocusPinsWordStepEvenWithManualOverrideElsewhere() { + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: .record, + manualOverride: .verify, + isRecordingLocked: false, + isWordFieldFocused: true + ) + XCTAssertEqual(resolved, .word) + } + + func testManualOverrideWinsOverDerivedStepWhenNoLockOrFocus() { + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: .record, + manualOverride: .verify, + isRecordingLocked: false, + isWordFieldFocused: false + ) + XCTAssertEqual(resolved, .verify) + } + + func testFallsBackToDerivedStepWithNoOverrideLockOrFocus() { + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: .record, + manualOverride: nil, + isRecordingLocked: false, + isWordFieldFocused: false + ) + XCTAssertEqual(resolved, .record) + } + + func testRecordingLockWinsEvenWithWordFieldFocused() { + // Recording lock is priority 1, above word-field focus (priority 2). + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: .word, + manualOverride: nil, + isRecordingLocked: true, + isWordFieldFocused: true + ) + XCTAssertEqual(resolved, .record) + } +} From 060fbc1c63c398b7edc014140b263262acb2eabf Mon Sep 17 00:00:00 2001 From: shreeraman arunachalam karikalan Date: Mon, 20 Jul 2026 04:49:25 -0700 Subject: [PATCH 2/4] Address review: announcement latch, verify errors, lint - Reset lastAnnouncedTrainingStep on all teardown paths so the step-3 VoiceOver cue fires again on retry (resetTrainingVerificationAttempts, removeTrainingVariant) - Show save-failure errors in the Verify step body (footer previously lived only in the Record body) - Drop the unused trainingVariantsIsEmpty param from isOutputCovered - Introduce DictionaryTrainingSnapshot to collapse the step-model param packs (fixes function_parameter_count) and move accordion helpers into an extension (fixes type_body_length); no behavior change --- Sources/Fluid/UI/CustomDictionaryView.swift | 755 +++++++++--------- .../UI/DictionaryTrainingStepModel.swift | 100 +-- .../DictionaryTrainingStepModelTests.swift | 39 +- 3 files changed, 442 insertions(+), 452 deletions(-) diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index ae53210f..2107f083 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -107,32 +107,6 @@ struct CustomDictionaryView: View { return self.canRetryTrainingAfterMaximum ? "Try Again" : "Start" } - private var trainingFinalOutputIsReady: Bool { - DictionaryTrainingStepModel.finalOutputIsReady( - normalizedWord: self.normalizedTrainingReplacement, - consecutiveCoveredCaptures: self.consecutiveCoveredCaptures, - pronunciationEnrollmentCount: self.trainingPronunciationEnrollments.count, - lastTrainingOutput: self.lastTrainingOutput, - lastTrainingOutputIsCovered: self.lastTrainingOutputIsCovered, - trainingVariantsIsEmpty: self.trainingVariants.isEmpty, - activePronunciationMatching: self.activePronunciationMatching, - readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount - ) - } - - private var trainingAlreadyCorrectWithoutReplacement: Bool { - DictionaryTrainingStepModel.alreadyCorrectWithoutReplacement( - normalizedWord: self.normalizedTrainingReplacement, - consecutiveCoveredCaptures: self.consecutiveCoveredCaptures, - pronunciationEnrollmentCount: self.trainingPronunciationEnrollments.count, - lastTrainingOutput: self.lastTrainingOutput, - lastTrainingOutputIsCovered: self.lastTrainingOutputIsCovered, - trainingVariantsIsEmpty: self.trainingVariants.isEmpty, - activePronunciationMatching: self.activePronunciationMatching, - readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount - ) - } - private var trainingReadinessProgress: Int { if self.activePronunciationMatching { return min(self.trainingPronunciationEnrollments.count, CustomDictionaryTrainingMerge.readyCoveredCount) @@ -153,61 +127,6 @@ struct CustomDictionaryView: View { // MARK: - Train by Voice accordion - private var isTrainingRecordingLocked: Bool { - self.isTrainingRecording || self.isTrainingStarting || self.isAutomaticTrainingEnabled - } - - private var isTrainingVerifyReady: Bool { - self.trainingFinalOutputIsReady || self.trainingAlreadyCorrectWithoutReplacement - } - - private var derivedTrainingStep: DictionaryTrainingStep { - DictionaryTrainingStepModel.derivedStep( - normalizedWord: self.normalizedTrainingReplacement, - consecutiveCoveredCaptures: self.consecutiveCoveredCaptures, - pronunciationEnrollmentCount: self.trainingPronunciationEnrollments.count, - lastTrainingOutput: self.lastTrainingOutput, - lastTrainingOutputIsCovered: self.lastTrainingOutputIsCovered, - trainingVariantsIsEmpty: self.trainingVariants.isEmpty, - activePronunciationMatching: self.activePronunciationMatching, - readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount, - hasReachedVerify: self.hasReachedVerifyStep - ) - } - - private var expandedTrainingStep: DictionaryTrainingStep { - DictionaryTrainingStepModel.resolveExpandedStep( - derived: self.derivedTrainingStep, - manualOverride: self.manualExpandedTrainingStep, - isRecordingLocked: self.isTrainingRecordingLocked, - isWordFieldFocused: self.isTrainingWordFieldFocused - ) - } - - /// True when the user manually reopened step ① after already advancing past it - /// (word non-empty and some voice training progress exists). Used to show the - /// "editing restarts training" caption. - private var isReopeningTrainingWordStepAfterProgress: Bool { - self.manualExpandedTrainingStep == .word && - self.derivedTrainingStep != .word && - (self.trainingSampleCount > 0 || !self.trainingPronunciationEnrollments.isEmpty) - } - - /// A step header is tappable unless the recording lock pins `.record`, or the - /// word is still empty (Record/Verify have nothing to act on and would strand - /// the user on a disabled panel with no caption). - private func isTrainingStepInteractive(_ step: DictionaryTrainingStep) -> Bool { - if self.isTrainingRecordingLocked && step != .record { return false } - if step != .word && self.normalizedTrainingReplacement.isEmpty { return false } - return true - } - - private func selectTrainingStep(_ step: DictionaryTrainingStep) { - guard self.isTrainingStepInteractive(step) else { return } - self.manualExpandedTrainingStep = step - self.isTrainingWordFieldFocused = step == .word - } - private var trainingFinalOutputText: String { guard !self.lastTrainingOutput.isEmpty else { return "Record to check" } return self.trainingOutputIsCovered ? self.normalizedTrainingReplacement : self.lastTrainingOutput @@ -543,318 +462,98 @@ struct CustomDictionaryView: View { ) } - private var trainReplacementComposer: some View { - VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { - self.trainingStepHeader(.word) - if self.expandedTrainingStep == .word { - self.trainingWordStepBody - } + private var manualReplacementComposer: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.md) { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: self.theme.metrics.spacing.md) { + self.manualTriggerField + self.manualReplacementField + } - self.trainingStepHeader(.record) - if self.expandedTrainingStep == .record { - self.trainingRecordStepBody + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.md) { + self.manualTriggerField + self.manualReplacementField + } } - self.trainingStepHeader(.verify) - if self.expandedTrainingStep == .verify { - self.trainingVerifyStepBody - } - } - .animation(self.reduceMotion ? nil : .easeInOut(duration: 0.22), value: self.expandedTrainingStep) - .task { - await DictionaryTrainingEndpointMonitor.shared.prepare() - } - // Attached to the always-mounted accordion (not the conditionally-rendered - // word TextField) so a programmatic write to trainingReplacement while step ① - // is collapsed still resets progress/latch/coverage. - .onChange(of: self.trainingReplacement) { oldValue, newValue in - self.handleTrainingReplacementChange(oldValue: oldValue, newValue: newValue) - } - .onChange(of: self.derivedTrainingStep) { _, _ in - self.manualExpandedTrainingStep = nil - } - .onChange(of: self.expandedTrainingStep) { oldStep, newStep in - self.announceTrainingStepEdgeIfNeeded(from: oldStep, to: newStep) - } - .onChange(of: self.isTrainingRecordingLocked) { _, isLocked in - if isLocked { - self.manualExpandedTrainingStep = nil + if !self.manualDuplicateTriggers.isEmpty { + Label("Already used: \(self.manualDuplicateTriggers.joined(separator: ", "))", systemImage: "exclamationmark.triangle.fill") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.warning) } - } - .onChange(of: self.isTrainingVerifyReady) { _, isReady in - if isReady { - self.hasReachedVerifyStep = true + + if !self.manualTriggers.isEmpty || !self.manualReplacement.isEmpty { + FlowLayout(spacing: 6) { + ForEach(self.manualTriggers, id: \.self) { trigger in + DictionaryPreviewChip(text: trigger) + } + + Image(systemName: "arrow.right") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.tertiaryText) + + Text(self.manualReplacement.trimmingCharacters(in: .whitespacesAndNewlines)) + .font(self.theme.typography.captionStrong) + .foregroundStyle(self.theme.palette.accent) + } } - } - } - private func announceTrainingStepEdgeIfNeeded(from oldStep: DictionaryTrainingStep, to newStep: DictionaryTrainingStep) { - guard oldStep != newStep, self.lastAnnouncedTrainingStep != newStep else { return } - // Any advance in step order is a forward edge — including the word→verify jump - // when captures are already sufficient (advanceFromWordStep resolving to .verify). - guard newStep.rawValue > oldStep.rawValue else { return } - self.lastAnnouncedTrainingStep = newStep - AccessibilityNotification.Announcement(DictionaryTrainingCopy.stepAnnouncement(for: newStep)).post() - } + Spacer(minLength: 0) - @ViewBuilder - private func trainingStepHeader(_ step: DictionaryTrainingStep) -> some View { - let isInteractive = self.isTrainingStepInteractive(step) - DictionaryTrainingStepHeaderView( - step: step, - status: self.trainingStepStatus(step), - title: DictionaryTrainingCopy.stepTitle(step), - subtitle: self.trainingStepSubtitle(step), - isExpanded: self.expandedTrainingStep == step, - isInteractive: isInteractive - ) { - self.selectTrainingStep(step) + Button { + self.addManualReplacementIfValid() + } label: { + Label("Add Replacement", systemImage: "plus") + .frame(maxWidth: .infinity) + .frame(height: 38) + } + .fluidButton(.accent, size: .small) + .disabled(!self.canAddManualReplacement) + .opacity(self.canAddManualReplacement ? 1 : 0.45) } } - private func trainingStepStatus(_ step: DictionaryTrainingStep) -> DictionaryTrainingStepHeaderView.Status { - if step.rawValue < self.derivedTrainingStep.rawValue { - return .complete - } - if step == self.derivedTrainingStep { - return .current - } - return .upcoming - } + private var manualTriggerField: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + Text("When FluidVoice hears") + .font(self.theme.typography.captionStrong) - private func trainingStepSubtitle(_ step: DictionaryTrainingStep) -> String { - switch step { - case .word: - return DictionaryTrainingCopy.wordStepSubtitle( - normalizedWord: self.normalizedTrainingReplacement, - isPastWordStep: self.derivedTrainingStep != .word - ) - case .record: - let isPreloaded = self.trainingSampleCount == 0 && !self.trainingVariants.isEmpty - return DictionaryTrainingCopy.recordStepSubtitle( - derivedStep: self.derivedTrainingStep, - preloadedCaptureCount: isPreloaded ? self.trainingVariants.count : nil, - progress: self.trainingReadinessProgress, - total: CustomDictionaryTrainingMerge.readyCoveredCount - ) - case .verify: - return DictionaryTrainingCopy.verifyStepSubtitle(isReady: self.isTrainingVerifyReady) + TextField("fluid voice, fluid boys", text: self.$manualTriggerDraft) + .dictionaryInputChrome() + .onSubmit { self.addManualReplacementIfValid() } + + Text("Separate different versions with commas. Enter only commas to replace comma punctuation.") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) } } - @ViewBuilder - private var trainingWordStepBody: some View { + private var manualReplacementField: some View { VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { - TextField("Type the correct text, e.g. FluidVoice", text: self.$trainingReplacement) + Text("Change it to") + .font(self.theme.typography.captionStrong) + TextField("FluidVoice", text: self.$manualReplacement) .dictionaryInputChrome() - .disabled(self.isTrainingRecording || self.isTrainingProcessing) - .focused(self.$isTrainingWordFieldFocused) - .onSubmit { - self.advanceFromWordStep() - } - - if self.isReopeningTrainingWordStepAfterProgress { - Label(DictionaryTrainingCopy.editingWordRestartsTrainingCaption, systemImage: "exclamationmark.circle") - .font(self.theme.typography.caption) - .foregroundStyle(self.theme.palette.warning) - } + .onSubmit { self.addManualReplacementIfValid() } + Text("This is what appears in your transcription.") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) } - .padding(.leading, self.trainingStepBodyLeadingInset) } - /// Commits the typed word and advances past step ①. Blurring the field drops - /// the focus pin so `expandedTrainingStep` resolves to the derived step - /// (`.record`, or `.verify` when captures are already sufficient). No-op while - /// the word is empty so Tab/Return can't strand the user on an empty Record step. - private func advanceFromWordStep() { - guard !self.normalizedTrainingReplacement.isEmpty else { return } - self.manualExpandedTrainingStep = nil - self.isTrainingWordFieldFocused = false + private var voiceMatchingSettingsRow: some View { + VoiceMatchingSettingsRow( + isEnabled: self.pronunciationMatchingBinding, + isDisabled: self.isTrainingRecording || self.isTrainingProcessing, + isAdvancedAvailable: SettingsStore.shared.selectedSpeechModel.supportsPronunciationMatching, + onChange: self.handlePronunciationMatchingChange(enabled:) + ) } - @ViewBuilder - private var trainingRecordStepBody: some View { - VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { - self.voiceMatchingSettingsRow - - self.trainingRecorderPanel - - if let caption = self.trainingStartDisabledCaption { - Label(caption, systemImage: "info.circle") - .font(self.theme.typography.caption) - .foregroundStyle(self.theme.palette.secondaryText) - } - - if !self.trainingVariants.isEmpty { - self.trainingHeardSection - } - - self.trainingFooter - } - .padding(.leading, self.trainingStepBodyLeadingInset) - } - - /// Copy for the three Start-disabled causes reachable in step ②. Word-empty is - /// excluded by construction: the Record header is non-interactive while the word - /// is empty (isTrainingStepInteractive), and the derived step is `.word` anyway, - /// so this body never renders without a word. - private var trainingStartDisabledCaption: String? { - if self.asr.isRunning && !self.isTrainingRecording && !self.isTrainingStarting && !self.isAutomaticTrainingEnabled { - return DictionaryTrainingCopy.dictationRunningCaption - } - if self.isTrainingProcessing { - return DictionaryTrainingCopy.trainingProcessingCaption - } - if self.trainingSampleCount >= CustomDictionaryTrainingMerge.maxSamples { - return DictionaryTrainingCopy.maxSamplesReachedCaption - } - return nil - } - - @ViewBuilder - private var trainingVerifyStepBody: some View { - VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { - self.trainingFinalOutputPanel - - Button { - Task { await self.addTrainedReplacement() } - } label: { - Label( - self.trainedReplacementButtonTitle, - systemImage: self.shouldEmphasizeTrainedReplacementButton - ? "sparkles" - : (self.trainingAlreadyCorrectWithoutReplacement ? "checkmark" : "plus") - ) - .frame(maxWidth: .infinity) - .frame(height: 38) - } - .fluidButton(self.shouldEmphasizeTrainedReplacementButton ? .accent : .compact, size: .small) - .disabled(!self.canAddTrainedReplacement) - .opacity(self.canAddTrainedReplacement ? 1 : 0.62) - .overlay(self.trainedReplacementButtonReadyOutline) - .shadow( - color: self.shouldEmphasizeTrainedReplacementButton - ? self.theme.palette.accent.opacity(self.isTrainedReplacementGlowExpanded ? 0.34 : 0.14) - : .clear, - radius: self.shouldEmphasizeTrainedReplacementButton - ? (self.isTrainedReplacementGlowExpanded ? 18 : 8) - : 0, - x: 0, - y: 4 - ) - .onHover { self.isTrainedReplacementButtonHovered = $0 } - .onAppear { self.updateTrainedReplacementGlow() } - .onChange(of: self.shouldPulseTrainedReplacementButton) { _, _ in - self.updateTrainedReplacementGlow() - } - } - .padding(.leading, self.trainingStepBodyLeadingInset) - } - - private var trainingStepBodyLeadingInset: CGFloat { 28 } - - private var trainedReplacementButtonReadyOutline: some View { - RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) - .stroke( - self.shouldEmphasizeTrainedReplacementButton ? self.theme.palette.success.opacity(0.72) : .clear, - lineWidth: 1.5 - ) - .padding(-3) - .allowsHitTesting(false) - } - - private var manualReplacementComposer: some View { - VStack(alignment: .leading, spacing: self.theme.metrics.spacing.md) { - ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: self.theme.metrics.spacing.md) { - self.manualTriggerField - self.manualReplacementField - } - - VStack(alignment: .leading, spacing: self.theme.metrics.spacing.md) { - self.manualTriggerField - self.manualReplacementField - } - } - - if !self.manualDuplicateTriggers.isEmpty { - Label("Already used: \(self.manualDuplicateTriggers.joined(separator: ", "))", systemImage: "exclamationmark.triangle.fill") - .font(self.theme.typography.caption) - .foregroundStyle(self.theme.palette.warning) - } - - if !self.manualTriggers.isEmpty || !self.manualReplacement.isEmpty { - FlowLayout(spacing: 6) { - ForEach(self.manualTriggers, id: \.self) { trigger in - DictionaryPreviewChip(text: trigger) - } - - Image(systemName: "arrow.right") - .font(self.theme.typography.caption) - .foregroundStyle(self.theme.palette.tertiaryText) - - Text(self.manualReplacement.trimmingCharacters(in: .whitespacesAndNewlines)) - .font(self.theme.typography.captionStrong) - .foregroundStyle(self.theme.palette.accent) - } - } - - Spacer(minLength: 0) - - Button { - self.addManualReplacementIfValid() - } label: { - Label("Add Replacement", systemImage: "plus") - .frame(maxWidth: .infinity) - .frame(height: 38) - } - .fluidButton(.accent, size: .small) - .disabled(!self.canAddManualReplacement) - .opacity(self.canAddManualReplacement ? 1 : 0.45) - } - } - - private var manualTriggerField: some View { - VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { - Text("When FluidVoice hears") - .font(self.theme.typography.captionStrong) - - TextField("fluid voice, fluid boys", text: self.$manualTriggerDraft) - .dictionaryInputChrome() - .onSubmit { self.addManualReplacementIfValid() } - - Text("Separate different versions with commas. Enter only commas to replace comma punctuation.") - .font(self.theme.typography.caption) - .foregroundStyle(self.theme.palette.secondaryText) - } - } - - private var manualReplacementField: some View { - VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { - Text("Change it to") - .font(self.theme.typography.captionStrong) - TextField("FluidVoice", text: self.$manualReplacement) - .dictionaryInputChrome() - .onSubmit { self.addManualReplacementIfValid() } - Text("This is what appears in your transcription.") - .font(self.theme.typography.caption) - .foregroundStyle(self.theme.palette.secondaryText) - } - } - - private var voiceMatchingSettingsRow: some View { - VoiceMatchingSettingsRow( - isEnabled: self.pronunciationMatchingBinding, - isDisabled: self.isTrainingRecording || self.isTrainingProcessing, - isAdvancedAvailable: SettingsStore.shared.selectedSpeechModel.supportsPronunciationMatching, - onChange: self.handlePronunciationMatchingChange(enabled:) - ) - } - - private var trainingRecorderPanel: some View { - VStack(alignment: .leading, spacing: self.theme.metrics.spacing.md) { - Text("Teach FluidVoice your pronunciation") - .font(self.theme.typography.bodySmallStrong) + private var trainingRecorderPanel: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.md) { + Text("Teach FluidVoice your pronunciation") + .font(self.theme.typography.bodySmallStrong) if self.trainingAlreadyCorrectWithoutReplacement { Label("\(self.trainingTargetReference) is already recognized correctly.", systemImage: "checkmark.circle.fill") @@ -2093,6 +1792,9 @@ struct CustomDictionaryView: View { // Tearing down verification progress must drop the Verify latch, otherwise // the accordion stays stuck on step ③ (Save disabled) after Try Again. self.hasReachedVerifyStep = false + // Reset the announcement latch too, so the step-③ VoiceOver cue fires again + // when the user records enough to reach Verify a second time. + self.lastAnnouncedTrainingStep = .word } private func addTrainingVariant(from transcript: String) { @@ -2216,6 +1918,7 @@ struct CustomDictionaryView: View { // remaining captures are still sufficient, `finalOutputIsReady` re-derives // `.verify` on its own. self.hasReachedVerifyStep = false + self.lastAnnouncedTrainingStep = .word } private func refreshLastTrainingCoverage() { @@ -2521,6 +2224,314 @@ private extension CustomDictionaryView { } } +// MARK: - Train by Voice accordion (moved out of the primary struct body to keep +// type_body_length in check; behavior is identical to inline declarations). +private extension CustomDictionaryView { + var trainingSnapshot: DictionaryTrainingSnapshot { + DictionaryTrainingSnapshot( + normalizedWord: self.normalizedTrainingReplacement, + consecutiveCoveredCaptures: self.consecutiveCoveredCaptures, + pronunciationEnrollmentCount: self.trainingPronunciationEnrollments.count, + lastTrainingOutput: self.lastTrainingOutput, + lastTrainingOutputIsCovered: self.lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: self.trainingVariants.isEmpty, + activePronunciationMatching: self.activePronunciationMatching + ) + } + + var trainingFinalOutputIsReady: Bool { + DictionaryTrainingStepModel.finalOutputIsReady( + self.trainingSnapshot, + readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount + ) + } + + var trainingAlreadyCorrectWithoutReplacement: Bool { + DictionaryTrainingStepModel.alreadyCorrectWithoutReplacement( + self.trainingSnapshot, + readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount + ) + } + + var isTrainingRecordingLocked: Bool { + self.isTrainingRecording || self.isTrainingStarting || self.isAutomaticTrainingEnabled + } + + var isTrainingVerifyReady: Bool { + self.trainingFinalOutputIsReady || self.trainingAlreadyCorrectWithoutReplacement + } + + var derivedTrainingStep: DictionaryTrainingStep { + DictionaryTrainingStepModel.derivedStep( + self.trainingSnapshot, + readyCoveredCount: CustomDictionaryTrainingMerge.readyCoveredCount, + hasReachedVerify: self.hasReachedVerifyStep + ) + } + + var expandedTrainingStep: DictionaryTrainingStep { + DictionaryTrainingStepModel.resolveExpandedStep( + derived: self.derivedTrainingStep, + manualOverride: self.manualExpandedTrainingStep, + isRecordingLocked: self.isTrainingRecordingLocked, + isWordFieldFocused: self.isTrainingWordFieldFocused + ) + } + + /// True when the user manually reopened step ① after already advancing past it + /// (word non-empty and some voice training progress exists). Used to show the + /// "editing restarts training" caption. + var isReopeningTrainingWordStepAfterProgress: Bool { + self.manualExpandedTrainingStep == .word && + self.derivedTrainingStep != .word && + (self.trainingSampleCount > 0 || !self.trainingPronunciationEnrollments.isEmpty) + } + + /// A step header is tappable unless the recording lock pins `.record`, or the + /// word is still empty (Record/Verify have nothing to act on and would strand + /// the user on a disabled panel with no caption). + func isTrainingStepInteractive(_ step: DictionaryTrainingStep) -> Bool { + if self.isTrainingRecordingLocked && step != .record { return false } + if step != .word && self.normalizedTrainingReplacement.isEmpty { return false } + return true + } + + func selectTrainingStep(_ step: DictionaryTrainingStep) { + guard self.isTrainingStepInteractive(step) else { return } + self.manualExpandedTrainingStep = step + self.isTrainingWordFieldFocused = step == .word + } + + var trainReplacementComposer: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + self.trainingStepHeader(.word) + if self.expandedTrainingStep == .word { + self.trainingWordStepBody + } + + self.trainingStepHeader(.record) + if self.expandedTrainingStep == .record { + self.trainingRecordStepBody + } + + self.trainingStepHeader(.verify) + if self.expandedTrainingStep == .verify { + self.trainingVerifyStepBody + } + } + .animation(self.reduceMotion ? nil : .easeInOut(duration: 0.22), value: self.expandedTrainingStep) + .task { + await DictionaryTrainingEndpointMonitor.shared.prepare() + } + // Attached to the always-mounted accordion (not the conditionally-rendered + // word TextField) so a programmatic write to trainingReplacement while step ① + // is collapsed still resets progress/latch/coverage. + .onChange(of: self.trainingReplacement) { oldValue, newValue in + self.handleTrainingReplacementChange(oldValue: oldValue, newValue: newValue) + } + .onChange(of: self.derivedTrainingStep) { _, _ in + self.manualExpandedTrainingStep = nil + } + .onChange(of: self.expandedTrainingStep) { oldStep, newStep in + self.announceTrainingStepEdgeIfNeeded(from: oldStep, to: newStep) + } + .onChange(of: self.isTrainingRecordingLocked) { _, isLocked in + if isLocked { + self.manualExpandedTrainingStep = nil + } + } + .onChange(of: self.isTrainingVerifyReady) { _, isReady in + if isReady { + self.hasReachedVerifyStep = true + } + } + } + + func announceTrainingStepEdgeIfNeeded(from oldStep: DictionaryTrainingStep, to newStep: DictionaryTrainingStep) { + guard oldStep != newStep, self.lastAnnouncedTrainingStep != newStep else { return } + // Any advance in step order is a forward edge — including the word→verify jump + // when captures are already sufficient (advanceFromWordStep resolving to .verify). + guard newStep.rawValue > oldStep.rawValue else { return } + self.lastAnnouncedTrainingStep = newStep + AccessibilityNotification.Announcement(DictionaryTrainingCopy.stepAnnouncement(for: newStep)).post() + } + + @ViewBuilder + func trainingStepHeader(_ step: DictionaryTrainingStep) -> some View { + let isInteractive = self.isTrainingStepInteractive(step) + DictionaryTrainingStepHeaderView( + step: step, + status: self.trainingStepStatus(step), + title: DictionaryTrainingCopy.stepTitle(step), + subtitle: self.trainingStepSubtitle(step), + isExpanded: self.expandedTrainingStep == step, + isInteractive: isInteractive + ) { + self.selectTrainingStep(step) + } + } + + func trainingStepStatus(_ step: DictionaryTrainingStep) -> DictionaryTrainingStepHeaderView.Status { + if step.rawValue < self.derivedTrainingStep.rawValue { + return .complete + } + if step == self.derivedTrainingStep { + return .current + } + return .upcoming + } + + func trainingStepSubtitle(_ step: DictionaryTrainingStep) -> String { + switch step { + case .word: + return DictionaryTrainingCopy.wordStepSubtitle( + normalizedWord: self.normalizedTrainingReplacement, + isPastWordStep: self.derivedTrainingStep != .word + ) + case .record: + let isPreloaded = self.trainingSampleCount == 0 && !self.trainingVariants.isEmpty + return DictionaryTrainingCopy.recordStepSubtitle( + derivedStep: self.derivedTrainingStep, + preloadedCaptureCount: isPreloaded ? self.trainingVariants.count : nil, + progress: self.trainingReadinessProgress, + total: CustomDictionaryTrainingMerge.readyCoveredCount + ) + case .verify: + return DictionaryTrainingCopy.verifyStepSubtitle(isReady: self.isTrainingVerifyReady) + } + } + + @ViewBuilder + var trainingWordStepBody: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + TextField("Type the correct text, e.g. FluidVoice", text: self.$trainingReplacement) + .dictionaryInputChrome() + .disabled(self.isTrainingRecording || self.isTrainingProcessing) + .focused(self.$isTrainingWordFieldFocused) + .onSubmit { + self.advanceFromWordStep() + } + + if self.isReopeningTrainingWordStepAfterProgress { + Label(DictionaryTrainingCopy.editingWordRestartsTrainingCaption, systemImage: "exclamationmark.circle") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.warning) + } + } + .padding(.leading, self.trainingStepBodyLeadingInset) + } + + /// Commits the typed word and advances past step ①. Blurring the field drops + /// the focus pin so `expandedTrainingStep` resolves to the derived step + /// (`.record`, or `.verify` when captures are already sufficient). No-op while + /// the word is empty so Tab/Return can't strand the user on an empty Record step. + func advanceFromWordStep() { + guard !self.normalizedTrainingReplacement.isEmpty else { return } + self.manualExpandedTrainingStep = nil + self.isTrainingWordFieldFocused = false + } + + @ViewBuilder + var trainingRecordStepBody: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + self.voiceMatchingSettingsRow + + self.trainingRecorderPanel + + if let caption = self.trainingStartDisabledCaption { + Label(caption, systemImage: "info.circle") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.secondaryText) + } + + if !self.trainingVariants.isEmpty { + self.trainingHeardSection + } + + self.trainingFooter + } + .padding(.leading, self.trainingStepBodyLeadingInset) + } + + /// Copy for the three Start-disabled causes reachable in step ②. Word-empty is + /// excluded by construction: the Record header is non-interactive while the word + /// is empty (isTrainingStepInteractive), and the derived step is `.word` anyway, + /// so this body never renders without a word. + var trainingStartDisabledCaption: String? { + if self.asr.isRunning && !self.isTrainingRecording && !self.isTrainingStarting && !self.isAutomaticTrainingEnabled { + return DictionaryTrainingCopy.dictationRunningCaption + } + if self.isTrainingProcessing { + return DictionaryTrainingCopy.trainingProcessingCaption + } + if self.trainingSampleCount >= CustomDictionaryTrainingMerge.maxSamples { + return DictionaryTrainingCopy.maxSamplesReachedCaption + } + return nil + } + + @ViewBuilder + var trainingVerifyStepBody: some View { + VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + self.trainingFinalOutputPanel + + // Surface save failures here too: addTrainedReplacement can fail on this + // step (e.g. voice-profile save), and the error footer only renders in + // the Record body. + if self.trainingHasError { + Label(self.trainingStatusMessage, systemImage: "exclamationmark.triangle.fill") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.warning) + } + + Button { + Task { await self.addTrainedReplacement() } + } label: { + Label( + self.trainedReplacementButtonTitle, + systemImage: self.shouldEmphasizeTrainedReplacementButton + ? "sparkles" + : (self.trainingAlreadyCorrectWithoutReplacement ? "checkmark" : "plus") + ) + .frame(maxWidth: .infinity) + .frame(height: 38) + } + .fluidButton(self.shouldEmphasizeTrainedReplacementButton ? .accent : .compact, size: .small) + .disabled(!self.canAddTrainedReplacement) + .opacity(self.canAddTrainedReplacement ? 1 : 0.62) + .overlay(self.trainedReplacementButtonReadyOutline) + .shadow( + color: self.shouldEmphasizeTrainedReplacementButton + ? self.theme.palette.accent.opacity(self.isTrainedReplacementGlowExpanded ? 0.34 : 0.14) + : .clear, + radius: self.shouldEmphasizeTrainedReplacementButton + ? (self.isTrainedReplacementGlowExpanded ? 18 : 8) + : 0, + x: 0, + y: 4 + ) + .onHover { self.isTrainedReplacementButtonHovered = $0 } + .onAppear { self.updateTrainedReplacementGlow() } + .onChange(of: self.shouldPulseTrainedReplacementButton) { _, _ in + self.updateTrainedReplacementGlow() + } + } + .padding(.leading, self.trainingStepBodyLeadingInset) + } + + var trainingStepBodyLeadingInset: CGFloat { 28 } + + var trainedReplacementButtonReadyOutline: some View { + RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) + .stroke( + self.shouldEmphasizeTrainedReplacementButton ? self.theme.palette.success.opacity(0.72) : .clear, + lineWidth: 1.5 + ) + .padding(-3) + .allowsHitTesting(false) + } +} + private struct VoiceMatchingSettingsRow: View { @Binding var isEnabled: Bool diff --git a/Sources/Fluid/UI/DictionaryTrainingStepModel.swift b/Sources/Fluid/UI/DictionaryTrainingStepModel.swift index 799be11f..2bca07db 100644 --- a/Sources/Fluid/UI/DictionaryTrainingStepModel.swift +++ b/Sources/Fluid/UI/DictionaryTrainingStepModel.swift @@ -16,11 +16,21 @@ enum DictionaryTrainingStep: Int, CaseIterable, Equatable { case verify } +/// Immutable snapshot of the primitive training state the step model derives from. +struct DictionaryTrainingSnapshot: Equatable { + let normalizedWord: String + let consecutiveCoveredCaptures: Int + let pronunciationEnrollmentCount: Int + let lastTrainingOutput: String + let lastTrainingOutputIsCovered: Bool + let trainingVariantsIsEmpty: Bool + let activePronunciationMatching: Bool +} + enum DictionaryTrainingStepModel { /// Replicates `trainingOutputIsCovered` (CustomDictionaryView.swift ~141-146). private static func isOutputCovered( lastTrainingOutputIsCovered: Bool, - trainingVariantsIsEmpty: Bool, pronunciationEnrollmentCount: Int, activePronunciationMatching: Bool ) -> Bool { @@ -33,69 +43,49 @@ enum DictionaryTrainingStepModel { /// Single source of truth for `trainingAlreadyCorrectWithoutReplacement`; /// CustomDictionaryView delegates to this. static func alreadyCorrectWithoutReplacement( - normalizedWord: String, - consecutiveCoveredCaptures: Int, - pronunciationEnrollmentCount: Int, - lastTrainingOutput: String, - lastTrainingOutputIsCovered: Bool, - trainingVariantsIsEmpty: Bool, - activePronunciationMatching: Bool, + _ snapshot: DictionaryTrainingSnapshot, readyCoveredCount: Int ) -> Bool { - if activePronunciationMatching { - return trainingVariantsIsEmpty && - !lastTrainingOutput.isEmpty && - lastTrainingOutput.caseInsensitiveCompare(normalizedWord) == .orderedSame && - pronunciationEnrollmentCount >= readyCoveredCount + if snapshot.activePronunciationMatching { + return snapshot.trainingVariantsIsEmpty && + !snapshot.lastTrainingOutput.isEmpty && + snapshot.lastTrainingOutput.caseInsensitiveCompare(snapshot.normalizedWord) == .orderedSame && + snapshot.pronunciationEnrollmentCount >= readyCoveredCount } let outputIsCovered = self.isOutputCovered( - lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, - trainingVariantsIsEmpty: trainingVariantsIsEmpty, - pronunciationEnrollmentCount: pronunciationEnrollmentCount, - activePronunciationMatching: activePronunciationMatching + lastTrainingOutputIsCovered: snapshot.lastTrainingOutputIsCovered, + pronunciationEnrollmentCount: snapshot.pronunciationEnrollmentCount, + activePronunciationMatching: snapshot.activePronunciationMatching ) - return trainingVariantsIsEmpty && + return snapshot.trainingVariantsIsEmpty && outputIsCovered && - !lastTrainingOutput.isEmpty && - lastTrainingOutput.caseInsensitiveCompare(normalizedWord) == .orderedSame && - consecutiveCoveredCaptures >= readyCoveredCount + !snapshot.lastTrainingOutput.isEmpty && + snapshot.lastTrainingOutput.caseInsensitiveCompare(snapshot.normalizedWord) == .orderedSame && + snapshot.consecutiveCoveredCaptures >= readyCoveredCount } /// Single source of truth for `trainingFinalOutputIsReady`; /// CustomDictionaryView delegates to this. static func finalOutputIsReady( - normalizedWord: String, - consecutiveCoveredCaptures: Int, - pronunciationEnrollmentCount: Int, - lastTrainingOutput: String, - lastTrainingOutputIsCovered: Bool, - trainingVariantsIsEmpty: Bool, - activePronunciationMatching: Bool, + _ snapshot: DictionaryTrainingSnapshot, readyCoveredCount: Int ) -> Bool { let alreadyCorrect = self.alreadyCorrectWithoutReplacement( - normalizedWord: normalizedWord, - consecutiveCoveredCaptures: consecutiveCoveredCaptures, - pronunciationEnrollmentCount: pronunciationEnrollmentCount, - lastTrainingOutput: lastTrainingOutput, - lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, - trainingVariantsIsEmpty: trainingVariantsIsEmpty, - activePronunciationMatching: activePronunciationMatching, + snapshot, readyCoveredCount: readyCoveredCount ) - if activePronunciationMatching { - return !alreadyCorrect && pronunciationEnrollmentCount >= readyCoveredCount + if snapshot.activePronunciationMatching { + return !alreadyCorrect && snapshot.pronunciationEnrollmentCount >= readyCoveredCount } let outputIsCovered = self.isOutputCovered( - lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, - trainingVariantsIsEmpty: trainingVariantsIsEmpty, - pronunciationEnrollmentCount: pronunciationEnrollmentCount, - activePronunciationMatching: activePronunciationMatching + lastTrainingOutputIsCovered: snapshot.lastTrainingOutputIsCovered, + pronunciationEnrollmentCount: snapshot.pronunciationEnrollmentCount, + activePronunciationMatching: snapshot.activePronunciationMatching ) - return !alreadyCorrect && outputIsCovered && consecutiveCoveredCaptures >= readyCoveredCount + return !alreadyCorrect && outputIsCovered && snapshot.consecutiveCoveredCaptures >= readyCoveredCount } /// Pure derivation of which step the composer logically represents right now, @@ -107,36 +97,18 @@ enum DictionaryTrainingStepModel { /// capture from snapping the accordion back to `.record`). /// - `.record` otherwise. static func derivedStep( - normalizedWord: String, - consecutiveCoveredCaptures: Int, - pronunciationEnrollmentCount: Int, - lastTrainingOutput: String, - lastTrainingOutputIsCovered: Bool, - trainingVariantsIsEmpty: Bool, - activePronunciationMatching: Bool, + _ snapshot: DictionaryTrainingSnapshot, readyCoveredCount: Int, hasReachedVerify: Bool ) -> DictionaryTrainingStep { - guard !normalizedWord.isEmpty else { return .word } + guard !snapshot.normalizedWord.isEmpty else { return .word } let ready = self.finalOutputIsReady( - normalizedWord: normalizedWord, - consecutiveCoveredCaptures: consecutiveCoveredCaptures, - pronunciationEnrollmentCount: pronunciationEnrollmentCount, - lastTrainingOutput: lastTrainingOutput, - lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, - trainingVariantsIsEmpty: trainingVariantsIsEmpty, - activePronunciationMatching: activePronunciationMatching, + snapshot, readyCoveredCount: readyCoveredCount ) let alreadyCorrect = self.alreadyCorrectWithoutReplacement( - normalizedWord: normalizedWord, - consecutiveCoveredCaptures: consecutiveCoveredCaptures, - pronunciationEnrollmentCount: pronunciationEnrollmentCount, - lastTrainingOutput: lastTrainingOutput, - lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, - trainingVariantsIsEmpty: trainingVariantsIsEmpty, - activePronunciationMatching: activePronunciationMatching, + snapshot, readyCoveredCount: readyCoveredCount ) diff --git a/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift b/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift index 1f5f7c07..c8fa0c69 100644 --- a/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift +++ b/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift @@ -14,14 +14,17 @@ final class DictionaryTrainingStepModelTests: XCTestCase { activePronunciationMatching: Bool = false, hasReachedVerify: Bool = false ) -> DictionaryTrainingStep { - DictionaryTrainingStepModel.derivedStep( + let snapshot = DictionaryTrainingSnapshot( normalizedWord: word, consecutiveCoveredCaptures: consecutiveCoveredCaptures, pronunciationEnrollmentCount: pronunciationEnrollmentCount, lastTrainingOutput: lastTrainingOutput, lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, trainingVariantsIsEmpty: trainingVariantsIsEmpty, - activePronunciationMatching: activePronunciationMatching, + activePronunciationMatching: activePronunciationMatching + ) + return DictionaryTrainingStepModel.derivedStep( + snapshot, readyCoveredCount: self.readyCoveredCount, hasReachedVerify: hasReachedVerify ) @@ -145,13 +148,15 @@ final class DictionaryTrainingStepModelTests: XCTestCase { activePronunciationMatching: Bool = false ) -> Bool { DictionaryTrainingStepModel.finalOutputIsReady( - normalizedWord: word, - consecutiveCoveredCaptures: consecutiveCoveredCaptures, - pronunciationEnrollmentCount: pronunciationEnrollmentCount, - lastTrainingOutput: lastTrainingOutput, - lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, - trainingVariantsIsEmpty: trainingVariantsIsEmpty, - activePronunciationMatching: activePronunciationMatching, + DictionaryTrainingSnapshot( + normalizedWord: word, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching + ), readyCoveredCount: self.readyCoveredCount ) } @@ -166,13 +171,15 @@ final class DictionaryTrainingStepModelTests: XCTestCase { activePronunciationMatching: Bool = false ) -> Bool { DictionaryTrainingStepModel.alreadyCorrectWithoutReplacement( - normalizedWord: word, - consecutiveCoveredCaptures: consecutiveCoveredCaptures, - pronunciationEnrollmentCount: pronunciationEnrollmentCount, - lastTrainingOutput: lastTrainingOutput, - lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, - trainingVariantsIsEmpty: trainingVariantsIsEmpty, - activePronunciationMatching: activePronunciationMatching, + DictionaryTrainingSnapshot( + normalizedWord: word, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching + ), readyCoveredCount: self.readyCoveredCount ) } From b9ddb2e22615858b81e428fb92993f18d5e7ac82 Mon Sep 17 00:00:00 2001 From: shreeraman arunachalam karikalan Date: Mon, 20 Jul 2026 09:01:39 -0700 Subject: [PATCH 3/4] Apply swiftformat to CustomDictionaryView --- Sources/Fluid/UI/CustomDictionaryView.swift | 51 +++++++++++++-------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index 2107f083..ba53d814 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -2185,7 +2185,9 @@ struct CustomDictionaryView: View { } private extension CustomDictionaryView { - var asr: ASRService { self.appServices.asr } + var asr: ASRService { + self.appServices.asr + } var trainedReplacementButtonTitle: String { self.trainingAlreadyCorrectWithoutReplacement ? "Nothing to Save" : "Add Replacement" @@ -2224,8 +2226,10 @@ private extension CustomDictionaryView { } } -// MARK: - Train by Voice accordion (moved out of the primary struct body to keep -// type_body_length in check; behavior is identical to inline declarations). +// MARK: - Train by Voice accordion + +/// Moved out of the primary struct body to keep type_body_length in check; +/// behavior is identical to the inline declarations. private extension CustomDictionaryView { var trainingSnapshot: DictionaryTrainingSnapshot { DictionaryTrainingSnapshot( @@ -2401,7 +2405,6 @@ private extension CustomDictionaryView { } } - @ViewBuilder var trainingWordStepBody: some View { VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { TextField("Type the correct text, e.g. FluidVoice", text: self.$trainingReplacement) @@ -2431,7 +2434,6 @@ private extension CustomDictionaryView { self.isTrainingWordFieldFocused = false } - @ViewBuilder var trainingRecordStepBody: some View { VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { self.voiceMatchingSettingsRow @@ -2458,7 +2460,7 @@ private extension CustomDictionaryView { /// is empty (isTrainingStepInteractive), and the derived step is `.word` anyway, /// so this body never renders without a word. var trainingStartDisabledCaption: String? { - if self.asr.isRunning && !self.isTrainingRecording && !self.isTrainingStarting && !self.isAutomaticTrainingEnabled { + if self.asr.isRunning, !self.isTrainingRecording, !self.isTrainingStarting, !self.isAutomaticTrainingEnabled { return DictionaryTrainingCopy.dictationRunningCaption } if self.isTrainingProcessing { @@ -2470,7 +2472,6 @@ private extension CustomDictionaryView { return nil } - @ViewBuilder var trainingVerifyStepBody: some View { VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { self.trainingFinalOutputPanel @@ -2519,7 +2520,9 @@ private extension CustomDictionaryView { .padding(.leading, self.trainingStepBodyLeadingInset) } - var trainingStepBodyLeadingInset: CGFloat { 28 } + var trainingStepBodyLeadingInset: CGFloat { + 28 + } var trainedReplacementButtonReadyOutline: some View { RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) @@ -2608,9 +2611,11 @@ private struct VoiceMatchingSettingsRow: View { .fill( isSelected ? self.theme.palette.accent - : (isHovered - ? self.theme.palette.accent.opacity(0.1) - : self.theme.palette.cardBackground.opacity(0.5)) + : ( + isHovered + ? self.theme.palette.accent.opacity(0.1) + : self.theme.palette.cardBackground.opacity(0.5) + ) ) .overlay( RoundedRectangle(cornerRadius: 7, style: .continuous) @@ -2832,7 +2837,9 @@ private enum DictionaryComposerMode: CaseIterable, Identifiable { case train case manual - var id: Self { self } + var id: Self { + self + } var title: String { switch self { @@ -2911,9 +2918,11 @@ private struct DictionaryComposerModeTab: View { .fill( self.isSelected ? self.theme.palette.accent - : (self.isHovered - ? self.theme.palette.accent.opacity(0.1) - : self.theme.palette.cardBackground.opacity(0.5)) + : ( + self.isHovered + ? self.theme.palette.accent.opacity(0.1) + : self.theme.palette.cardBackground.opacity(0.5) + ) ) .overlay( RoundedRectangle(cornerRadius: self.theme.metrics.corners.sm, style: .continuous) @@ -2976,9 +2985,11 @@ private struct DictionaryTrainingStepHeaderView: View { .fill( self.isExpanded ? self.theme.palette.contentBackground.opacity(0.55) - : (self.isHovered - ? self.theme.palette.contentBackground.opacity(0.32) - : Color.clear) + : ( + self.isHovered + ? self.theme.palette.contentBackground.opacity(0.32) + : Color.clear + ) ) .overlay( RoundedRectangle(cornerRadius: self.theme.metrics.corners.md, style: .continuous) @@ -3357,7 +3368,9 @@ private enum BoostStrengthPreset: String, CaseIterable, Identifiable { case balanced = "Balanced" case strong = "Strong" - var id: String { self.rawValue } + var id: String { + self.rawValue + } var weight: Float { switch self { From 98109795bc7ff59d3607bdcb6cb14b20380cb91c Mon Sep 17 00:00:00 2001 From: Shreeraman A K <16458670+shreeraman96@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:11:57 -0700 Subject: [PATCH 4/4] fix: address accordion review findings - Show real progress in Record subtitle when Verify is latched after a miss - Render heard-variant chips and already-correct caption in the Verify body - Move training error label to an always-visible spot below the accordion - Distinguish 'No replacement needed' from 'Ready to save' in Verify header - Lower VoiceOver announcement latch on backward step transitions - Defer word-field focus until the step body is mounted - Dedupe coverage and recording-lock predicates; extract resetTrainingStepLatches() - Pin latch-state subtitle copy in tests; use production readyCoveredCount --- Sources/Fluid/UI/CustomDictionaryView.swift | 133 ++++++++++-------- .../UI/DictionaryTrainingStepModel.swift | 44 +++++- .../DictionaryTrainingStepModelTests.swift | 130 ++++++++++++++++- 3 files changed, 242 insertions(+), 65 deletions(-) diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index ba53d814..f70486a5 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -97,7 +97,7 @@ struct CustomDictionaryView: View { } private var trainingRecorderIsStop: Bool { - self.isAutomaticTrainingEnabled || self.isTrainingRecording || self.isTrainingStarting + self.isTrainingRecordingLocked } private var trainingRecorderButtonTitle: String { @@ -119,10 +119,11 @@ struct CustomDictionaryView: View { } private var trainingOutputIsCovered: Bool { - if self.activePronunciationMatching { - return !self.trainingPronunciationEnrollments.isEmpty - } - return self.lastTrainingOutputIsCovered + DictionaryTrainingStepModel.isOutputCovered( + lastTrainingOutputIsCovered: self.lastTrainingOutputIsCovered, + pronunciationEnrollmentCount: self.trainingPronunciationEnrollments.count, + activePronunciationMatching: self.activePronunciationMatching + ) } // MARK: - Train by Voice accordion @@ -728,12 +729,6 @@ struct CustomDictionaryView: View { private var trainingFooter: some View { if self.trainingHasError || self.isTrainingActive || !self.trainingVariants.isEmpty { HStack(spacing: self.theme.metrics.spacing.sm) { - if self.trainingHasError { - Label(self.trainingStatusMessage, systemImage: "exclamationmark.triangle.fill") - .font(self.theme.typography.caption) - .foregroundStyle(self.theme.palette.warning) - } - if self.isTrainingActive || !self.trainingVariants.isEmpty || !self.normalizedTrainingReplacement.isEmpty { Spacer() @@ -1782,6 +1777,11 @@ struct CustomDictionaryView: View { await self.startTrainingSample() } + private func resetTrainingStepLatches() { + self.hasReachedVerifyStep = false + self.lastAnnouncedTrainingStep = .word + } + private func resetTrainingVerificationAttempts() { self.trainingSampleCount = 0 self.lastTrainingOutput = "" @@ -1789,12 +1789,10 @@ struct CustomDictionaryView: View { self.consecutiveCoveredCaptures = 0 self.trainingStatusMessage = "" self.trainingHasError = false - // Tearing down verification progress must drop the Verify latch, otherwise - // the accordion stays stuck on step ③ (Save disabled) after Try Again. - self.hasReachedVerifyStep = false - // Reset the announcement latch too, so the step-③ VoiceOver cue fires again - // when the user records enough to reach Verify a second time. - self.lastAnnouncedTrainingStep = .word + // Tearing down verification progress must drop the Verify + announcement + // latches, otherwise the accordion stays stuck on step ③ (Save disabled) + // after Try Again and the VoiceOver cue won't re-fire on a second run. + self.resetTrainingStepLatches() } private func addTrainingVariant(from transcript: String) { @@ -1823,7 +1821,7 @@ struct CustomDictionaryView: View { self.trainingHasError = false if self.consecutiveCoveredCaptures >= CustomDictionaryTrainingMerge.readyCoveredCount { self.trainingStatusMessage = self.trainingVariants.isEmpty - ? "Looks good already. No replacement needed." + ? DictionaryTrainingCopy.alreadyCorrectCaption : "Looks ready. Add this replacement when you're ready." } else { self.trainingStatusMessage = "Covered. Try a couple more." @@ -1913,12 +1911,11 @@ struct CustomDictionaryView: View { private func removeTrainingVariant(_ variant: String) { self.trainingVariants.removeAll { $0 == variant } self.refreshLastTrainingCoverage() - // Removing a capture may drop us below ready; clear the latch so the derived - // step re-computes from live coverage instead of pinning Verify. If the - // remaining captures are still sufficient, `finalOutputIsReady` re-derives - // `.verify` on its own. - self.hasReachedVerifyStep = false - self.lastAnnouncedTrainingStep = .word + // Removing a capture may drop us below ready; clear the latches so the + // derived step re-computes from live coverage instead of pinning Verify. If + // the remaining captures are still sufficient, `finalOutputIsReady` + // re-derives `.verify` on its own. + self.resetTrainingStepLatches() } private func refreshLastTrainingCoverage() { @@ -1958,9 +1955,8 @@ struct CustomDictionaryView: View { self.isTrainingRecording = false self.trainingStopRequestedDuringStart = false self.isTrainingProcessing = false - self.hasReachedVerifyStep = false + self.resetTrainingStepLatches() self.manualExpandedTrainingStep = nil - self.lastAnnouncedTrainingStep = .word } private func handleTrainingReplacementChange(oldValue: String, newValue: String) { @@ -1975,8 +1971,7 @@ struct CustomDictionaryView: View { self.lastTrainingOutputIsCovered = false self.consecutiveCoveredCaptures = 0 self.isTrainingActive = false - self.hasReachedVerifyStep = false - self.lastAnnouncedTrainingStep = .word + self.resetTrainingStepLatches() if newKey.isEmpty { self.trainingStatusMessage = "Type the correct text." } else if self.trainingVariants.isEmpty { @@ -2303,7 +2298,16 @@ private extension CustomDictionaryView { func selectTrainingStep(_ step: DictionaryTrainingStep) { guard self.isTrainingStepInteractive(step) else { return } self.manualExpandedTrainingStep = step - self.isTrainingWordFieldFocused = step == .word + if step == .word { + // Defer focus until the expansion has been committed to the next runloop + // turn so the word TextField exists when the FocusState is written — + // programmatic focus to a not-yet-rendered field is unreliable on macOS. + Task { @MainActor in + self.isTrainingWordFieldFocused = true + } + } else { + self.isTrainingWordFieldFocused = false + } } var trainReplacementComposer: some View { @@ -2322,6 +2326,15 @@ private extension CustomDictionaryView { if self.expandedTrainingStep == .verify { self.trainingVerifyStepBody } + + // Always-visible error label: rendered once outside the collapsible step + // bodies so a recording/processing failure is surfaced regardless of + // which step is expanded. + if self.trainingHasError { + Label(self.trainingStatusMessage, systemImage: "exclamationmark.triangle.fill") + .font(self.theme.typography.caption) + .foregroundStyle(self.theme.palette.warning) + } } .animation(self.reduceMotion ? nil : .easeInOut(duration: 0.22), value: self.expandedTrainingStep) .task { @@ -2352,10 +2365,18 @@ private extension CustomDictionaryView { } func announceTrainingStepEdgeIfNeeded(from oldStep: DictionaryTrainingStep, to newStep: DictionaryTrainingStep) { - guard oldStep != newStep, self.lastAnnouncedTrainingStep != newStep else { return } + guard oldStep != newStep else { return } + // Backward transition (e.g. Verify → Record after a miss): lower the + // announcement latch so a later forward re-advance re-announces. Without + // this the latch stays pinned at the furthest step reached and a VoiceOver + // user who backtracks then re-advances hears nothing the second time. + if newStep.rawValue < self.lastAnnouncedTrainingStep.rawValue { + self.lastAnnouncedTrainingStep = newStep + } // Any advance in step order is a forward edge — including the word→verify jump // when captures are already sufficient (advanceFromWordStep resolving to .verify). - guard newStep.rawValue > oldStep.rawValue else { return } + // The same-step guard below keeps a step from announcing twice in a row. + guard newStep.rawValue > oldStep.rawValue, self.lastAnnouncedTrainingStep != newStep else { return } self.lastAnnouncedTrainingStep = newStep AccessibilityNotification.Announcement(DictionaryTrainingCopy.stepAnnouncement(for: newStep)).post() } @@ -2377,6 +2398,12 @@ private extension CustomDictionaryView { func trainingStepStatus(_ step: DictionaryTrainingStep) -> DictionaryTrainingStepHeaderView.Status { if step.rawValue < self.derivedTrainingStep.rawValue { + // When Verify is derived only via the post-ready-miss latch (captures no + // longer sufficient), Record is still the active step — don't mark it + // complete based on the latch alone. + if step == .record, self.derivedTrainingStep == .verify, !self.isTrainingVerifyReady { + return .current + } return .complete } if step == self.derivedTrainingStep { @@ -2394,14 +2421,17 @@ private extension CustomDictionaryView { ) case .record: let isPreloaded = self.trainingSampleCount == 0 && !self.trainingVariants.isEmpty - return DictionaryTrainingCopy.recordStepSubtitle( + return DictionaryTrainingStepCopy.recordStepSubtitle( derivedStep: self.derivedTrainingStep, preloadedCaptureCount: isPreloaded ? self.trainingVariants.count : nil, progress: self.trainingReadinessProgress, total: CustomDictionaryTrainingMerge.readyCoveredCount ) case .verify: - return DictionaryTrainingCopy.verifyStepSubtitle(isReady: self.isTrainingVerifyReady) + return DictionaryTrainingStepCopy.verifyStepSubtitle( + isReady: self.trainingFinalOutputIsReady, + isAlreadyCorrect: self.trainingAlreadyCorrectWithoutReplacement + ) } } @@ -2474,15 +2504,16 @@ private extension CustomDictionaryView { var trainingVerifyStepBody: some View { VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { + if !self.trainingVariants.isEmpty { + self.trainingHeardSection + } + self.trainingFinalOutputPanel - // Surface save failures here too: addTrainedReplacement can fail on this - // step (e.g. voice-profile save), and the error footer only renders in - // the Record body. - if self.trainingHasError { - Label(self.trainingStatusMessage, systemImage: "exclamationmark.triangle.fill") + if self.trainingAlreadyCorrectWithoutReplacement { + Label(DictionaryTrainingCopy.alreadyCorrectCaption, systemImage: "checkmark.circle.fill") .font(self.theme.typography.caption) - .foregroundStyle(self.theme.palette.warning) + .foregroundStyle(self.theme.palette.accent) } Button { @@ -2804,33 +2835,11 @@ private enum DictionaryTrainingCopy { isPastWordStep ? normalizedWord : "Type the word to teach" } - static func recordStepSubtitle( - derivedStep: DictionaryTrainingStep, - preloadedCaptureCount: Int?, - progress: Int, - total: Int - ) -> String { - switch derivedStep { - case .word: - return "Waiting for word…" - case .verify: - return "✓ Recognized \(total)/\(total)" - case .record: - if let preloadedCaptureCount { - return "Loaded \(preloadedCaptureCount) saved \(preloadedCaptureCount == 1 ? "capture" : "captures")" - } - return "Recorded \(progress)/\(total) — keep going" - } - } - - static func verifyStepSubtitle(isReady: Bool) -> String { - isReady ? "Ready to save" : "—" - } - static let editingWordRestartsTrainingCaption = "Editing the word restarts voice training." static let dictationRunningCaption = "Dictation is running — stop dictating to train." static let trainingProcessingCaption = "Processing…" static let maxSamplesReachedCaption = "Max samples reached — press Try Again or Clear." + static let alreadyCorrectCaption = "Looks good already. No replacement needed." } private enum DictionaryComposerMode: CaseIterable, Identifiable { diff --git a/Sources/Fluid/UI/DictionaryTrainingStepModel.swift b/Sources/Fluid/UI/DictionaryTrainingStepModel.swift index 2bca07db..47f7c6c0 100644 --- a/Sources/Fluid/UI/DictionaryTrainingStepModel.swift +++ b/Sources/Fluid/UI/DictionaryTrainingStepModel.swift @@ -28,8 +28,9 @@ struct DictionaryTrainingSnapshot: Equatable { } enum DictionaryTrainingStepModel { - /// Replicates `trainingOutputIsCovered` (CustomDictionaryView.swift ~141-146). - private static func isOutputCovered( + /// Single source of truth for whether the last training output counts as + /// "covered"; `CustomDictionaryView.trainingOutputIsCovered` delegates to this. + static func isOutputCovered( lastTrainingOutputIsCovered: Bool, pronunciationEnrollmentCount: Int, activePronunciationMatching: Bool @@ -145,3 +146,42 @@ enum DictionaryTrainingStepModel { return derived } } + +/// Testable copy for the "Train by Voice" accordion step subtitles. Kept free of +/// SwiftUI/@State so the latched post-ready-miss subtitle and the already-correct +/// subtitle can be pinned by unit tests. +enum DictionaryTrainingStepCopy { + /// Subtitle for the Record step header. When the derived step is `.verify` only + /// because of the post-ready-miss latch (`progress < total`), the real progress + /// is shown instead of a false "Recognized total/total". + static func recordStepSubtitle( + derivedStep: DictionaryTrainingStep, + preloadedCaptureCount: Int?, + progress: Int, + total: Int + ) -> String { + switch derivedStep { + case .word: + return "Waiting for word…" + case .verify: + if progress >= total { + return "✓ Recognized \(total)/\(total)" + } + return "Recognized \(progress)/\(total) — record again" + case .record: + if let preloadedCaptureCount { + return "Loaded \(preloadedCaptureCount) saved \(preloadedCaptureCount == 1 ? "capture" : "captures")" + } + return "Recorded \(progress)/\(total) — keep going" + } + } + + /// Subtitle for the Verify step header. The already-correct-without-replacement + /// state (Save disabled, "Nothing to Save") is distinguished from ready-to-save. + static func verifyStepSubtitle(isReady: Bool, isAlreadyCorrect: Bool) -> String { + if isAlreadyCorrect { + return "No replacement needed" + } + return isReady ? "Ready to save" : "—" + } +} diff --git a/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift b/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift index c8fa0c69..1214fdc5 100644 --- a/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift +++ b/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift @@ -2,7 +2,9 @@ import XCTest final class DictionaryTrainingStepModelTests: XCTestCase { - private let readyCoveredCount = 3 + // Reference the production constant so a change to the ready threshold fails + // these tests instead of silently diverging from the view. + private var readyCoveredCount: Int { CustomDictionaryTrainingMerge.readyCoveredCount } private func derived( word: String = "FluidVoice", @@ -319,4 +321,130 @@ final class DictionaryTrainingStepModelTests: XCTestCase { ) XCTAssertEqual(resolved, .record) } + + // MARK: - Latched post-ready-miss subtitle + + /// Mirrors `CustomDictionaryView.trainingReadinessProgress` for the + /// non-pronunciation-matching branch, using the model's single-source predicates + /// so the test exercises the same progress the view would show. + private func readinessProgress( + consecutiveCoveredCaptures: Int, + lastTrainingOutputIsCovered: Bool, + pronunciationEnrollmentCount: Int = 0, + activePronunciationMatching: Bool = false, + trainingVariantsIsEmpty: Bool = true, + lastTrainingOutput: String = "FluidVoice", + normalizedWord: String = "FluidVoice" + ) -> Int { + let snapshot = DictionaryTrainingSnapshot( + normalizedWord: normalizedWord, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching + ) + let total = self.readyCoveredCount + if DictionaryTrainingStepModel.alreadyCorrectWithoutReplacement(snapshot, readyCoveredCount: total) { + return total + } + let covered = DictionaryTrainingStepModel.isOutputCovered( + lastTrainingOutputIsCovered: snapshot.lastTrainingOutputIsCovered, + pronunciationEnrollmentCount: snapshot.pronunciationEnrollmentCount, + activePronunciationMatching: snapshot.activePronunciationMatching + ) + return covered ? min(snapshot.consecutiveCoveredCaptures, total) : 0 + } + + func testLatchedPostReadyMissDoesNotClaimRecognizedTotal() { + // Post-ready missed capture: the verify latch holds derivedStep at .verify + // while consecutive covered captures reset to 0 and the last output is no + // longer covered. The Record header subtitle must show the real progress + // (0/total) and must NOT claim "Recognized total/total". + let total = self.readyCoveredCount + let snapshot = DictionaryTrainingSnapshot( + normalizedWord: "FluidVoice", + consecutiveCoveredCaptures: 0, + pronunciationEnrollmentCount: 0, + lastTrainingOutput: "fluid voice", + lastTrainingOutputIsCovered: false, + trainingVariantsIsEmpty: false, + activePronunciationMatching: false + ) + + let step = DictionaryTrainingStepModel.derivedStep( + snapshot, + readyCoveredCount: total, + hasReachedVerify: true + ) + XCTAssertEqual(step, .verify) + + let resolved = DictionaryTrainingStepModel.resolveExpandedStep( + derived: step, + manualOverride: nil, + isRecordingLocked: false, + isWordFieldFocused: false + ) + XCTAssertEqual(resolved, .verify) + + let progress = self.readinessProgress( + consecutiveCoveredCaptures: snapshot.consecutiveCoveredCaptures, + lastTrainingOutputIsCovered: snapshot.lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: snapshot.trainingVariantsIsEmpty, + lastTrainingOutput: snapshot.lastTrainingOutput + ) + XCTAssertEqual(progress, 0, "latched post-ready-miss must read zero progress") + + let subtitle = DictionaryTrainingStepCopy.recordStepSubtitle( + derivedStep: step, + preloadedCaptureCount: nil, + progress: progress, + total: total + ) + XCTAssertNotEqual(subtitle, "✓ Recognized \(total)/\(total)") + XCTAssertTrue(subtitle.contains("0/\(total)"), "expected real progress, got: \(subtitle)") + XCTAssertTrue(subtitle.contains("record again"), "expected a record-again nudge, got: \(subtitle)") + } + + func testGenuinelyReadyRecordSubtitleShowsRecognizedTotal() { + // Positive counterpart: when progress actually reaches total, the Record + // header subtitle shows the complete "✓ Recognized total/total". + let total = self.readyCoveredCount + let subtitle = DictionaryTrainingStepCopy.recordStepSubtitle( + derivedStep: .verify, + preloadedCaptureCount: nil, + progress: total, + total: total + ) + XCTAssertEqual(subtitle, "✓ Recognized \(total)/\(total)") + } + + // MARK: - Verify step subtitle copy + + func testVerifySubtitleAlreadyCorrectSaysNoReplacementNeeded() { + // alreadyCorrectWithoutReplacement: Save is disabled ("Nothing to Save"), so + // the Verify header must not say "Ready to save". + let subtitle = DictionaryTrainingStepCopy.verifyStepSubtitle( + isReady: false, + isAlreadyCorrect: true + ) + XCTAssertEqual(subtitle, "No replacement needed") + } + + func testVerifySubtitleReadySaysReadyToSave() { + let subtitle = DictionaryTrainingStepCopy.verifyStepSubtitle( + isReady: true, + isAlreadyCorrect: false + ) + XCTAssertEqual(subtitle, "Ready to save") + } + + func testVerifySubtitleNotReadyShowsPlaceholder() { + let subtitle = DictionaryTrainingStepCopy.verifyStepSubtitle( + isReady: false, + isAlreadyCorrect: false + ) + XCTAssertEqual(subtitle, "—") + } }