Voice: show dictated transcript in the Quick Add field
The mic captured speech and the transcript reached rawText, but it never appeared in the field. NLTaskField.updateUIView (UITextView representable) returned early when the field wasn't focused (`guard coordinator.isEditing`), so externally-set text (voice dictation, the normal not-focused case) was dropped and the placeholder stayed. Fix: render non-empty `text` even when not editing, placing the caret at the end; keep the cursor-preserving fast path while actively editing. No new voice system — this only fixes the existing mic button's transcript→field binding. Verified on simulator via the real speech.transcript path: "Buy milk tomorrow" → field shows it (chip: Tomorrow); "Call Romeo at 11 AM" → field shows it (chip: Today 11:00). Adds DEBUG-only test hooks KISANI_AUTOADD / KISANI_VOICE_SIM (compiled out of Release). Logged under KC-38. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,15 @@ struct ContentView: View {
|
|||||||
.presentationDetents([.height(150)])
|
.presentationDetents([.height(150)])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
.onAppear {
|
||||||
|
// Test hook: auto-open Quick Add (used to verify the voice→field binding
|
||||||
|
// on the simulator, which has no microphone). Compiled out of Release.
|
||||||
|
if ProcessInfo.processInfo.environment["KISANI_AUTOADD"] == "1" {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showQuickAddTask = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.onChange(of: showWorkoutTab) { _ in ShortcutHandler.registerShortcuts() }
|
.onChange(of: showWorkoutTab) { _ in ShortcutHandler.registerShortcuts() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1193,3 +1193,25 @@ Files: `SpeechRecognizer.swift` (new), `TodayView.swift`, `project.yml`.
|
|||||||
- Recognition locale is `.current`; no manual language picker.
|
- Recognition locale is `.current`; no manual language picker.
|
||||||
- Contains `print("[VOICE] …")` debug logging — harmless, could be removed before
|
- Contains `print("[VOICE] …")` debug logging — harmless, could be removed before
|
||||||
release. **Minor.**
|
release. **Minor.**
|
||||||
|
|
||||||
|
### Follow-up fix — transcript never appeared in the field
|
||||||
|
**Status:** Fixed (simulator-verified)
|
||||||
|
|
||||||
|
The mic captured speech and the transcript reached `rawText`, but the text never
|
||||||
|
showed in the Quick Add field. Root cause: `NLTaskField.updateUIView`
|
||||||
|
(`UIViewRepresentable` over `UITextView`) had `guard coordinator.isEditing else {
|
||||||
|
…return }` — so when the field wasn't focused (the normal case when tapping the
|
||||||
|
mic), externally-set text (voice) was dropped and the placeholder stayed. Fixed
|
||||||
|
to render non-empty `text` even when not editing (caret moved to end). Verified on
|
||||||
|
simulator by pushing text through the real `speech.transcript` path: "Buy milk
|
||||||
|
tomorrow" → field shows it, date chip = Tomorrow; "Call Romeo at 11 AM" → field
|
||||||
|
shows it, chip = Today 11:00.
|
||||||
|
|
||||||
|
Not exercised on the sim (no microphone / no UI-tap automation): live mic capture
|
||||||
|
and the literal Save tap. The save path is the same `submit()`/`addTask` used by
|
||||||
|
all typed tasks, so it's covered by normal task creation.
|
||||||
|
|
||||||
|
Added DEBUG-only test hooks (`KISANI_AUTOADD`, `KISANI_VOICE_SIM`) to inject a
|
||||||
|
transcript on the simulator; `#if DEBUG`, compiled out of Release.
|
||||||
|
|
||||||
|
Files: `TodayView.swift` (`NLTaskField.updateUIView`), `ContentView.swift`.
|
||||||
|
|||||||
@@ -1767,13 +1767,21 @@ struct NLTaskField: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ tv: UITextView, context: Context) {
|
func updateUIView(_ tv: UITextView, context: Context) {
|
||||||
guard context.coordinator.isEditing else {
|
let editing = context.coordinator.isEditing
|
||||||
if text.isEmpty {
|
|
||||||
|
// Not focused: show placeholder when empty, but still render text that was
|
||||||
|
// set programmatically (e.g. voice dictation) so it actually appears.
|
||||||
|
if !editing && text.isEmpty {
|
||||||
|
if tv.text != placeholder || tv.textColor != .placeholderText {
|
||||||
tv.text = placeholder
|
tv.text = placeholder
|
||||||
tv.textColor = .placeholderText
|
tv.textColor = .placeholderText
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Avoid clobbering the live UITextView (and the cursor) when the displayed
|
||||||
|
// text already matches the binding.
|
||||||
|
if tv.textColor == .label && tv.text == text && editing { return }
|
||||||
|
|
||||||
let sel = tv.selectedRange
|
let sel = tv.selectedRange
|
||||||
let attr = NSMutableAttributedString(string: text)
|
let attr = NSMutableAttributedString(string: text)
|
||||||
let full = NSRange(location: 0, length: attr.length)
|
let full = NSRange(location: 0, length: attr.length)
|
||||||
@@ -1785,7 +1793,9 @@ struct NLTaskField: UIViewRepresentable {
|
|||||||
], range: r)
|
], range: r)
|
||||||
}
|
}
|
||||||
tv.attributedText = attr
|
tv.attributedText = attr
|
||||||
tv.selectedRange = sel
|
// Only restore the caret while the user is actively editing; for an external
|
||||||
|
// (voice) update, place the caret at the end.
|
||||||
|
tv.selectedRange = editing ? sel : NSRange(location: attr.length, length: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||||
@@ -1862,6 +1872,15 @@ struct AddTaskSheet: View {
|
|||||||
rawText = text
|
rawText = text
|
||||||
print("[VOICE] Transcript bound to task field")
|
print("[VOICE] Transcript bound to task field")
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
.onAppear {
|
||||||
|
// Test hook: simulate a finished dictation by pushing text through the
|
||||||
|
// real speech.transcript path (the sim has no mic). Compiled out of Release.
|
||||||
|
if let t = ProcessInfo.processInfo.environment["KISANI_VOICE_SIM"], !t.isEmpty {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { speech.transcript = t }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 14)
|
.padding(.top, 14)
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
|
|||||||
Reference in New Issue
Block a user