From 641a1d10266b14bc8097386147ed9a7fce9a9b0f Mon Sep 17 00:00:00 2001 From: shreeraman arunachalam karikalan Date: Sun, 19 Jul 2026 23:16:32 -0700 Subject: [PATCH 1/3] 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 2c89a7fc..93c3d478 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( @@ -1873,6 +2086,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) { @@ -1991,6 +2207,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() { @@ -2030,6 +2251,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 +2268,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 { @@ -2281,7 +2507,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." : "" @@ -2532,6 +2761,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 { @@ -2633,6 +2912,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 85df724c64e7dd05d3a54ccb92759b19e4bddcc8 Mon Sep 17 00:00:00 2001 From: shreeraman arunachalam karikalan Date: Mon, 20 Jul 2026 04:49:25 -0700 Subject: [PATCH 2/3] 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 93c3d478..971c1783 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") @@ -2089,6 +1788,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) { @@ -2212,6 +1914,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() { @@ -2517,6 +2220,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 a99cfbd109577fafbc8367cfa333dcdc316a8885 Mon Sep 17 00:00:00 2001 From: shreeraman arunachalam karikalan Date: Mon, 20 Jul 2026 09:01:39 -0700 Subject: [PATCH 3/3] 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 971c1783..91ee587d 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -2181,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" @@ -2220,8 +2222,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( @@ -2397,7 +2401,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) @@ -2427,7 +2430,6 @@ private extension CustomDictionaryView { self.isTrainingWordFieldFocused = false } - @ViewBuilder var trainingRecordStepBody: some View { VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { self.voiceMatchingSettingsRow @@ -2454,7 +2456,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 { @@ -2466,7 +2468,6 @@ private extension CustomDictionaryView { return nil } - @ViewBuilder var trainingVerifyStepBody: some View { VStack(alignment: .leading, spacing: self.theme.metrics.spacing.sm) { self.trainingFinalOutputPanel @@ -2515,7 +2516,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) @@ -2604,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) @@ -2828,7 +2833,9 @@ private enum DictionaryComposerMode: CaseIterable, Identifiable { case train case manual - var id: Self { self } + var id: Self { + self + } var title: String { switch self { @@ -2907,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) @@ -2972,9 +2981,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) @@ -3353,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 {