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 2c89a7fc..91ee587d 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 = "" @@ -103,30 +107,6 @@ struct CustomDictionaryView: View { return self.canRetryTrainingAfterMaximum ? "Try Again" : "Start" } - private var trainingFinalOutputIsReady: Bool { - if self.activePronunciationMatching { - return !self.trainingAlreadyCorrectWithoutReplacement && - self.trainingPronunciationEnrollments.count >= CustomDictionaryTrainingMerge.readyCoveredCount - } - return !self.trainingAlreadyCorrectWithoutReplacement && - self.trainingOutputIsCovered && - self.consecutiveCoveredCaptures >= 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 - } - private var trainingReadinessProgress: Int { if self.activePronunciationMatching { return min(self.trainingPronunciationEnrollments.count, CustomDictionaryTrainingMerge.readyCoveredCount) @@ -145,6 +125,8 @@ struct CustomDictionaryView: View { return self.lastTrainingOutputIsCovered } + // MARK: - Train by Voice accordion + private var trainingFinalOutputText: String { guard !self.lastTrainingOutput.isEmpty else { return "Record to check" } return self.trainingOutputIsCovered ? self.normalizedTrainingReplacement : self.lastTrainingOutput @@ -480,76 +462,6 @@ struct CustomDictionaryView: View { ) } - private var trainReplacementComposer: 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) - } - - self.voiceMatchingSettingsRow - - self.trainingRecorderPanel - - self.trainingFinalOutputPanel - - if !self.trainingVariants.isEmpty { - self.trainingHeardSection - } - - self.trainingFooter - - Spacer(minLength: 0) - - 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() - } - } - .task { - await DictionaryTrainingEndpointMonitor.shared.prepare() - } - } - - 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) { @@ -1873,6 +1785,12 @@ 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 } private func addTrainingVariant(from transcript: String) { @@ -1991,6 +1909,12 @@ 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 } private func refreshLastTrainingCoverage() { @@ -2030,6 +1954,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) { @@ -2044,6 +1971,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 { @@ -2252,7 +2181,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" @@ -2281,13 +2212,325 @@ 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." : "" } } +// 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( + 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) + } + } + + 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 + } + + 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 + } + + 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 @@ -2364,9 +2607,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) @@ -2532,13 +2777,65 @@ 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 { case train case manual - var id: Self { self } + var id: Self { + self + } var title: String { switch self { @@ -2617,9 +2914,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) @@ -2633,6 +2932,114 @@ 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() @@ -2957,7 +3364,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 { diff --git a/Sources/Fluid/UI/DictionaryTrainingStepModel.swift b/Sources/Fluid/UI/DictionaryTrainingStepModel.swift new file mode 100644 index 00000000..2bca07db --- /dev/null +++ b/Sources/Fluid/UI/DictionaryTrainingStepModel.swift @@ -0,0 +1,147 @@ +// +// 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 +} + +/// 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, + 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( + _ snapshot: DictionaryTrainingSnapshot, + readyCoveredCount: Int + ) -> Bool { + if snapshot.activePronunciationMatching { + return snapshot.trainingVariantsIsEmpty && + !snapshot.lastTrainingOutput.isEmpty && + snapshot.lastTrainingOutput.caseInsensitiveCompare(snapshot.normalizedWord) == .orderedSame && + snapshot.pronunciationEnrollmentCount >= readyCoveredCount + } + + let outputIsCovered = self.isOutputCovered( + lastTrainingOutputIsCovered: snapshot.lastTrainingOutputIsCovered, + pronunciationEnrollmentCount: snapshot.pronunciationEnrollmentCount, + activePronunciationMatching: snapshot.activePronunciationMatching + ) + return snapshot.trainingVariantsIsEmpty && + outputIsCovered && + !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( + _ snapshot: DictionaryTrainingSnapshot, + readyCoveredCount: Int + ) -> Bool { + let alreadyCorrect = self.alreadyCorrectWithoutReplacement( + snapshot, + readyCoveredCount: readyCoveredCount + ) + + if snapshot.activePronunciationMatching { + return !alreadyCorrect && snapshot.pronunciationEnrollmentCount >= readyCoveredCount + } + + let outputIsCovered = self.isOutputCovered( + lastTrainingOutputIsCovered: snapshot.lastTrainingOutputIsCovered, + pronunciationEnrollmentCount: snapshot.pronunciationEnrollmentCount, + activePronunciationMatching: snapshot.activePronunciationMatching + ) + return !alreadyCorrect && outputIsCovered && snapshot.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( + _ snapshot: DictionaryTrainingSnapshot, + readyCoveredCount: Int, + hasReachedVerify: Bool + ) -> DictionaryTrainingStep { + guard !snapshot.normalizedWord.isEmpty else { return .word } + + let ready = self.finalOutputIsReady( + snapshot, + readyCoveredCount: readyCoveredCount + ) + let alreadyCorrect = self.alreadyCorrectWithoutReplacement( + snapshot, + 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..c8fa0c69 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/DictionaryTrainingStepModelTests.swift @@ -0,0 +1,322 @@ +@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 { + let snapshot = DictionaryTrainingSnapshot( + normalizedWord: word, + consecutiveCoveredCaptures: consecutiveCoveredCaptures, + pronunciationEnrollmentCount: pronunciationEnrollmentCount, + lastTrainingOutput: lastTrainingOutput, + lastTrainingOutputIsCovered: lastTrainingOutputIsCovered, + trainingVariantsIsEmpty: trainingVariantsIsEmpty, + activePronunciationMatching: activePronunciationMatching + ) + return DictionaryTrainingStepModel.derivedStep( + snapshot, + 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( + DictionaryTrainingSnapshot( + 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( + DictionaryTrainingSnapshot( + 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) + } +}