From e0257445efe50b897a98ae5f57f0ca9818530622 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 20 Jul 2026 01:41:42 -0400 Subject: [PATCH 1/7] Add Initial Voice Input --- android/app/capacitor.build.gradle | 2 + android/app/src/main/AndroidManifest.xml | 9 + android/capacitor.settings.gradle | 6 + ios/App/App/Info.plist | 4 + ios/App/Podfile | 2 + ios/App/Podfile.lock | 14 +- package-lock.json | 179 ++++++- package.json | 6 +- ...r-community+speech-recognition+7.0.1.patch | 26 + src/service/VoiceInputService.js | 256 +++++++++ src/views/components/AddTaskModal.jsx | 60 ++- src/views/components/SmartTaskTitleInput.jsx | 39 +- .../components/VoiceToTask/VoicePanel.css | 121 +++++ .../components/VoiceToTask/VoicePanel.jsx | 493 ++++++++++++++++++ .../components/VoiceToTask/parseVoiceTask.js | 201 +++++++ .../components/VoiceToTask/useVoiceToTask.js | 217 ++++++++ .../components/VoiceToTask/voiceNormalizer.js | 129 +++++ 17 files changed, 1747 insertions(+), 17 deletions(-) create mode 100644 patches/@capacitor-community+speech-recognition+7.0.1.patch create mode 100644 src/service/VoiceInputService.js create mode 100644 src/views/components/VoiceToTask/VoicePanel.css create mode 100644 src/views/components/VoiceToTask/VoicePanel.jsx create mode 100644 src/views/components/VoiceToTask/parseVoiceTask.js create mode 100644 src/views/components/VoiceToTask/useVoiceToTask.js create mode 100644 src/views/components/VoiceToTask/voiceNormalizer.js diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 739289a..932ca47 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,10 +9,12 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { + implementation project(':capacitor-community-speech-recognition') implementation project(':capacitor-community-sqlite') implementation project(':capacitor-app') implementation project(':capacitor-browser') implementation project(':capacitor-device') + implementation project(':capacitor-haptics') implementation project(':capacitor-local-llm') implementation project(':capacitor-local-notifications') implementation project(':capacitor-network') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1a34d79..2f15d14 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -117,4 +117,13 @@ + + + + + + + + + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index a50983d..a1b0775 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -2,6 +2,9 @@ include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') +include ':capacitor-community-speech-recognition' +project(':capacitor-community-speech-recognition').projectDir = new File('../node_modules/@capacitor-community/speech-recognition/android') + include ':capacitor-community-sqlite' project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android') @@ -14,6 +17,9 @@ project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/ include ':capacitor-device' project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android') +include ':capacitor-haptics' +project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') + include ':capacitor-local-llm' project(':capacitor-local-llm').projectDir = new File('../node_modules/@capacitor/local-llm/android') diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index b960eaf..ada4f3e 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -28,6 +28,10 @@ This app needs access to camera to take photos to attach to task or use as profile photo NSPhotoLibraryUsageDescription This app needs access to photo library to select images to attach to task or use as profile photo + NSMicrophoneUsageDescription + This app uses the microphone to let you create tasks by speaking + NSSpeechRecognitionUsageDescription + This app uses on-device speech recognition to turn your voice into tasks UIBackgroundModes UILaunchStoryboardName diff --git a/ios/App/Podfile b/ios/App/Podfile index 30b894a..b4e2a32 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -11,10 +11,12 @@ install! 'cocoapods', :disable_input_output_paths => true def capacitor_pods pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorCommunitySpeechRecognition', :path => '../../node_modules/@capacitor-community/speech-recognition' pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser' pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device' + pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' pod 'CapacitorLocalLlm', :path => '../../node_modules/@capacitor/local-llm' pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications' pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network' diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index 5f6f755..af25af6 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -18,6 +18,8 @@ PODS: - Capacitor - CapacitorBrowser (8.0.3): - Capacitor + - CapacitorCommunitySpeechRecognition (7.0.1): + - Capacitor - CapacitorCommunitySqlite (8.1.0): - Capacitor - SQLCipher @@ -25,6 +27,8 @@ PODS: - CapacitorCordova (8.4.1) - CapacitorDevice (8.0.2): - Capacitor + - CapacitorHaptics (8.0.2): + - Capacitor - CapacitorLocalLlm (1.0.0): - Capacitor - CapacitorLocalNotifications (8.2.0): @@ -149,9 +153,11 @@ DEPENDENCIES: - "Capacitor (from `../../node_modules/@capacitor/ios`)" - "CapacitorApp (from `../../node_modules/@capacitor/app`)" - "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)" + - "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)" - "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)" - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - "CapacitorDevice (from `../../node_modules/@capacitor/device`)" + - "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)" - "CapacitorLocalLlm (from `../../node_modules/@capacitor/local-llm`)" - "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)" - "CapacitorNetwork (from `../../node_modules/@capacitor/network`)" @@ -203,12 +209,16 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/app" CapacitorBrowser: :path: "../../node_modules/@capacitor/browser" + CapacitorCommunitySpeechRecognition: + :path: "../../node_modules/@capacitor-community/speech-recognition" CapacitorCommunitySqlite: :path: "../../node_modules/@capacitor-community/sqlite" CapacitorCordova: :path: "../../node_modules/@capacitor/ios" CapacitorDevice: :path: "../../node_modules/@capacitor/device" + CapacitorHaptics: + :path: "../../node_modules/@capacitor/haptics" CapacitorLocalLlm: :path: "../../node_modules/@capacitor/local-llm" CapacitorLocalNotifications: @@ -243,9 +253,11 @@ SPEC CHECKSUMS: Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650 CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69 + CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09 CapacitorDevice: 14cba6f88d1c3074cbf825fea977c8c526453ff8 + CapacitorHaptics: 296f771ecd89c7a1bd92a7b6826a7d268e2e70f5 CapacitorLocalLlm: a05516151a02923a9e7dae9949d3817e85e321f0 CapacitorLocalNotifications: 2615aa008f608b95d3921a778ee1988abf1e6148 CapacitorNetwork: 8812ce60d11fb63d8f2e4ba51a49b2e59892ebe2 @@ -283,6 +295,6 @@ SPEC CHECKSUMS: SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 -PODFILE CHECKSUM: 21b805bdbbb6ac4b8a3527ee8ef346ba0a55ef65 +PODFILE CHECKSUM: 1099083fe561f8852fcef1bfcff4051f45db9770 COCOAPODS: 1.16.2 diff --git a/package-lock.json b/package-lock.json index ee5471e..f496011 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,22 @@ { "name": "donetick", - "version": "1.2.15", + "version": "1.2.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "donetick", - "version": "1.2.15", + "version": "1.2.16", + "hasInstallScript": true, "dependencies": { + "@capacitor-community/speech-recognition": "^7.0.1", "@capacitor-community/sqlite": "^8.0.0", "@capacitor/android": "^8.0.0", "@capacitor/app": "^8.0.0", "@capacitor/browser": "^8.0.0", "@capacitor/core": "^8.0.0", "@capacitor/device": "^8.0.0", + "@capacitor/haptics": "^8.0.2", "@capacitor/ios": "^8.0.0", "@capacitor/local-llm": "^1.0.0", "@capacitor/local-notifications": "^8.0.0", @@ -94,6 +97,7 @@ "eslint-plugin-sort-keys-fix": "^1.1.2", "eslint-plugin-tailwindcss": "^3.13.1", "husky": "^8.0.3", + "patch-package": "^8.0.1", "postcss": "^8.4.32", "prettier": "^3.1.1", "prettier-plugin-tailwindcss": "^0.5.10", @@ -1464,6 +1468,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@capacitor-community/speech-recognition": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor-community/speech-recognition/-/speech-recognition-7.0.1.tgz", + "integrity": "sha512-ykpBZziR575X0eURO5vXaD9gVrXXC/7Ra2qql/2KP6/jxWOqAFuw4eKjSPjwdAgGE6a/Z+v8FJm4SVh57MVwDA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/@capacitor-community/sqlite": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-8.1.0.tgz", @@ -2057,6 +2070,15 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/haptics": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz", + "integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/ios": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.1.tgz", @@ -5518,6 +5540,13 @@ "node": ">=10.0.0" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/acorn": { "version": "8.15.0", "license": "MIT", @@ -6767,6 +6796,22 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/classlist-polyfill": { "version": "1.2.0", "license": "Unlicense" @@ -8677,6 +8722,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "dev": true, @@ -10135,11 +10190,38 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "dev": true, @@ -10165,6 +10247,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsonparse": { "version": "1.3.1", "dev": true, @@ -10235,6 +10327,16 @@ "node": ">=0.10.0" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "4.1.5", "dev": true, @@ -11322,6 +11424,79 @@ "cross-spawn": "^7.0.3" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/patch-package/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/path-exists": { "version": "4.0.0", "license": "MIT", diff --git a/package.json b/package.json index 11c19fd..4a3d849 100644 --- a/package.json +++ b/package.json @@ -35,15 +35,18 @@ "bump": "node bump-version.js patch", "bump:minor": "node bump-version.js minor", "bump:major": "node bump-version.js major", - "bump:patch": "node bump-version.js patch" + "bump:patch": "node bump-version.js patch", + "postinstall": "patch-package" }, "dependencies": { + "@capacitor-community/speech-recognition": "^7.0.1", "@capacitor-community/sqlite": "^8.0.0", "@capacitor/android": "^8.0.0", "@capacitor/app": "^8.0.0", "@capacitor/browser": "^8.0.0", "@capacitor/core": "^8.0.0", "@capacitor/device": "^8.0.0", + "@capacitor/haptics": "^8.0.2", "@capacitor/ios": "^8.0.0", "@capacitor/local-llm": "^1.0.0", "@capacitor/local-notifications": "^8.0.0", @@ -124,6 +127,7 @@ "eslint-plugin-sort-keys-fix": "^1.1.2", "eslint-plugin-tailwindcss": "^3.13.1", "husky": "^8.0.3", + "patch-package": "^8.0.1", "postcss": "^8.4.32", "prettier": "^3.1.1", "prettier-plugin-tailwindcss": "^0.5.10", diff --git a/patches/@capacitor-community+speech-recognition+7.0.1.patch b/patches/@capacitor-community+speech-recognition+7.0.1.patch new file mode 100644 index 0000000..851fc64 --- /dev/null +++ b/patches/@capacitor-community+speech-recognition+7.0.1.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java b/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java +index a99ea69..2bea32e 100644 +--- a/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java ++++ b/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java +@@ -168,6 +168,7 @@ public class SpeechRecognition extends Plugin implements Constants { + intent.putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, bridge.getActivity().getPackageName()); + intent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, partialResults); + intent.putExtra("android.speech.extra.DICTATION_MODE", partialResults); ++ intent.putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true); + + if (prompt != null) { + intent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt); +diff --git a/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift b/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift +index 5d1b35b..810277d 100644 +--- a/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift ++++ b/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift +@@ -79,6 +79,9 @@ public class SpeechRecognition: CAPPlugin { + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = partialResults ++ if #available(iOS 13, *), self.speechRecognizer?.supportsOnDeviceRecognition == true { ++ self.recognitionRequest?.requiresOnDeviceRecognition = true ++ } + + let inputNode: AVAudioInputNode = self.audioEngine!.inputNode + let format: AVAudioFormat = inputNode.outputFormat(forBus: 0) diff --git a/src/service/VoiceInputService.js b/src/service/VoiceInputService.js new file mode 100644 index 0000000..f752b17 --- /dev/null +++ b/src/service/VoiceInputService.js @@ -0,0 +1,256 @@ +import { Capacitor } from '@capacitor/core' + +// Platform-abstracted speech-to-text for voice task capture. +// Native: @capacitor-community/speech-recognition — uses the OS recognizer +// (on-device where the platform supports it, e.g. iOS dictation models). +// Web: Web Speech API (Chrome/Safari) — mainly for development. +// +// Callbacks: +// onPartial(text) — live transcript of the utterance in progress +// onSegment(text) — a finalized utterance (silence/pause boundary) +// onStateChange(bool) — listening started/stopped +// onError(code) — 'denied' | 'error' +// +// Neither OS gives unlimited continuous listening: Android's recognizer ends on +// silence and iOS sessions have a practical duration limit. This service runs a +// restart loop — each recognizer stop commits the buffered utterance as a +// segment and immediately starts a new session while active. The utterance +// boundary doubles as the task boundary. + +const SILENCE_COMMIT_MS = 2200 +const RESTART_DELAY_MS = 250 + +const START_OPTIONS = { + language: 'en-US', + maxResults: 1, + partialResults: true, + popup: false, +} + +class VoiceInputService { + constructor() { + this._active = false + this._callbacks = null + this._partial = '' + this._lastSpeechAt = 0 + this._silenceTimer = null + this._restarting = false + this._webRecognition = null + } + + get isNative() { + return Capacitor.isNativePlatform() + } + + async isSupported() { + if (this.isNative) { + try { + const { SpeechRecognition } = await import( + '@capacitor-community/speech-recognition' + ) + const { available } = await SpeechRecognition.available() + return !!available + } catch { + return false + } + } + return ( + typeof window !== 'undefined' && + !!(window.SpeechRecognition || window.webkitSpeechRecognition) + ) + } + + async requestPermission() { + if (!this.isNative) { + // Web prompts for the microphone on first start() + return 'granted' + } + try { + const { SpeechRecognition } = await import( + '@capacitor-community/speech-recognition' + ) + const current = await SpeechRecognition.checkPermissions() + if (current.speechRecognition === 'granted') return 'granted' + const res = await SpeechRecognition.requestPermissions() + return res.speechRecognition === 'granted' ? 'granted' : 'denied' + } catch { + return 'denied' + } + } + + async start(callbacks) { + if (this._active) return + this._callbacks = callbacks + this._active = true + this._partial = '' + this._lastSpeechAt = Date.now() + + if (this.isNative) { + await this._startNative() + // Web finalizes utterances itself via isFinal results; only the native + // path needs a silence watchdog to force utterance boundaries. + this._silenceTimer = setInterval(() => this._checkSilence(), 500) + } else { + this._startWeb() + } + this._callbacks?.onStateChange?.(true) + } + + async stop() { + if (!this._active) return + this._active = false + if (this._silenceTimer) { + clearInterval(this._silenceTimer) + this._silenceTimer = null + } + if (this.isNative) { + try { + const { SpeechRecognition } = await import( + '@capacitor-community/speech-recognition' + ) + await SpeechRecognition.stop() + await SpeechRecognition.removeAllListeners() + } catch { + // recognizer may already be stopped + } + } else if (this._webRecognition) { + const rec = this._webRecognition + this._webRecognition = null + try { + rec.stop() + } catch { + // already stopped + } + } + this._commitPartial() + this._callbacks?.onStateChange?.(false) + } + + _commitPartial() { + const text = this._partial.trim() + this._partial = '' + this._callbacks?.onPartial?.('') + if (text) this._callbacks?.onSegment?.(text) + } + + _checkSilence() { + if (!this._active || this._restarting) return + if ( + this._partial.trim() && + Date.now() - this._lastSpeechAt > SILENCE_COMMIT_MS + ) { + // A pause means the utterance (= task) is complete: cycle the recognizer + // so the buffer commits and a fresh session begins. + this._restartNative() + } + } + + async _startNative() { + const { SpeechRecognition } = await import( + '@capacitor-community/speech-recognition' + ) + await SpeechRecognition.removeAllListeners() + + await SpeechRecognition.addListener('partialResults', ({ matches }) => { + const text = matches?.[0] || '' + if (!text) return + this._partial = text + this._lastSpeechAt = Date.now() + this._callbacks?.onPartial?.(text) + }) + + await SpeechRecognition.addListener('listeningState', ({ status }) => { + if (status === 'stopped' && this._active && !this._restarting) { + // OS ended the session on its own (silence on Android, session limit + // on iOS) — commit and start over. + this._restartNative() + } + }) + + // With partialResults the transcript arrives via listeners; the promise's + // resolution/rejection timing differs per platform, so don't rely on it. + SpeechRecognition.start(START_OPTIONS).catch(() => { + if (this._active && !this._restarting) { + this._restartNative() + } + }) + } + + async _restartNative() { + if (this._restarting) return + this._restarting = true + try { + const { SpeechRecognition } = await import( + '@capacitor-community/speech-recognition' + ) + try { + await SpeechRecognition.stop() + } catch { + // already stopped + } + this._commitPartial() + // Let the OS recognizer tear down before starting a new session + await new Promise(r => setTimeout(r, RESTART_DELAY_MS)) + if (this._active) { + SpeechRecognition.start(START_OPTIONS).catch(() => {}) + this._lastSpeechAt = Date.now() + } + } finally { + this._restarting = false + } + } + + _startWeb() { + const SR = window.SpeechRecognition || window.webkitSpeechRecognition + const rec = new SR() + rec.continuous = true + rec.interimResults = true + rec.lang = 'en-US' + + rec.onresult = event => { + let interim = '' + for (let i = event.resultIndex; i < event.results.length; i++) { + const res = event.results[i] + if (res.isFinal) { + this._partial = '' + this._callbacks?.onPartial?.('') + const text = res[0].transcript.trim() + if (text) this._callbacks?.onSegment?.(text) + } else { + interim += res[0].transcript + } + } + if (interim) { + this._partial = interim + this._lastSpeechAt = Date.now() + this._callbacks?.onPartial?.(interim) + } + } + + rec.onend = () => { + if (this._active && this._webRecognition === rec) { + try { + rec.start() + } catch { + // restart can race with teardown + } + } + } + + rec.onerror = e => { + if (e.error === 'not-allowed' || e.error === 'service-not-allowed') { + this._callbacks?.onError?.('denied') + this.stop() + } + } + + this._webRecognition = rec + try { + rec.start() + } catch { + this._callbacks?.onError?.('error') + } + } +} + +export const voiceInputService = new VoiceInputService() diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 30ed6f5..bda067c 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -24,6 +24,7 @@ import SmartTaskTitleInput from './SmartTaskTitleInput' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { localAIService } from '../../service/LocalAIService' +import { voiceInputService } from '../../service/VoiceInputService' import { TASK_COLOR } from '../../utils/Colors' import AdvancedOptionsSection, { AdvancedOptionsTrigger, @@ -39,6 +40,8 @@ import RepeatPickerField from './RepeatPickerField' import RichTextEditor from './RichTextEditor' import ScanPanel from './ScanToTask/ScanPanel' import SubTasks from './SubTask' +import { buildChorePayload } from './VoiceToTask/parseVoiceTask' +import VoicePanel from './VoiceToTask/VoicePanel' const getDefaultNotification = () => { const storedDefault = localStorage.getItem('defaultNotificationTemplate') if (storedDefault) { @@ -121,10 +124,13 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { const [scanAutoCapture, setScanAutoCapture] = useState(false) const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null) const [llmAvailable, setLlmAvailable] = useState(false) + const [showVoice, setShowVoice] = useState(false) + const [voiceAvailable, setVoiceAvailable] = useState(false) const { isNativeScanner } = useDocumentScanner() useEffect(() => { localAIService.isAvailable().then(setLlmAvailable) + voiceInputService.isSupported().then(setVoiceAvailable) }, []) // Priority colors @@ -589,9 +595,46 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { } } + // Single voice-captured task: land it in the smart input so the user + // reviews it with the normal pickers before creating. + const handleVoiceSingle = text => { + setShowVoice(false) + processText(text) + } + + // Multiple voice-captured tasks: they were reviewed as cards in the panel, + // so create them all directly. + const handleVoiceCreateMany = async parsedTasks => { + const notificationTemplates = getDefaultNotification() + for (const parsed of parsedTasks) { + const chore = buildChorePayload(parsed, { + userProfile, + projectId, + notificationTemplates, + }) + try { + const result = await createChoreMutation.mutateAsync(chore) + if (result?._pendingCreate) { + onChoreUpdate(result) + } else { + onChoreUpdate({ + ...chore, + ...result, + id: result?.id, + nextDueDate: chore.dueDate, + }) + } + } catch (error) { + console.error('Error creating voice task:', error) + } + } + handleCloseModal(false) + } + const handleCloseModal = forceRefetch => { onClose(forceRefetch) setShowScan(false) + setShowVoice(false) setTaskText('') setTaskTitle('') setDueDate(null) @@ -761,7 +804,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { } > - {!showScan && ( + {!showScan && !showVoice && ( <> { } : undefined } + onVoiceClick={ + voiceAvailable ? () => setShowVoice(true) : undefined + } placeholder='Type your task...' onChange={text => { setTaskText(text) @@ -1097,6 +1143,18 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { )} + {showVoice && ( + setShowVoice(false)} + onUseSingle={handleVoiceSingle} + onCreateMany={handleVoiceCreateMany} + /> + )} + {showScan && ( { const { mode, setMode } = useColorScheme() const titleInputRef = useRef(null) @@ -200,13 +201,14 @@ const SmartTaskTitleInput = ({ e.target.value = '' } - const showNativeButtons = isNativeScanner && !value - const MIC_BUTTON_WIDTH = - showNativeButtons && onPhotoSelected && onScanClick - ? '5rem' - : showNativeButtons - ? '2.5rem' - : '0rem' + const showPhotoButtons = isNativeScanner && !value + const showVoiceButton = !!onVoiceClick && !value + const visibleButtonCount = + (showPhotoButtons && onPhotoSelected ? 1 : 0) + + (showPhotoButtons && onScanClick ? 1 : 0) + + (showVoiceButton ? 1 : 0) + const showActionButtons = visibleButtonCount > 0 + const ACTION_BUTTONS_WIDTH = `${visibleButtonCount * 2.5}rem` return (
@@ -223,7 +225,7 @@ const SmartTaskTitleInput = ({ position: 'absolute', top: 0, left: 0, - width: `calc(100% - ${MIC_BUTTON_WIDTH})`, + width: `calc(100% - ${ACTION_BUTTONS_WIDTH})`, height: '100%', zIndex: 1, resize: 'none', @@ -274,7 +276,7 @@ const SmartTaskTitleInput = ({ {/* Zero-width space to maintain consistent height */} ​
- {showNativeButtons && ( + {showActionButtons && ( - {onPhotoSelected && ( + {showPhotoButtons && onPhotoSelected && ( <> )} - {onScanClick && ( + {showPhotoButtons && onScanClick && ( )} + {showVoiceButton && ( + + + + + + )} )} diff --git a/src/views/components/VoiceToTask/VoicePanel.css b/src/views/components/VoiceToTask/VoicePanel.css new file mode 100644 index 0000000..f8648a7 --- /dev/null +++ b/src/views/components/VoiceToTask/VoicePanel.css @@ -0,0 +1,121 @@ +.voice-mic-btn { + position: relative; + width: 72px; + height: 72px; + border-radius: 50%; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: var(--joy-palette-primary-solidBg, #0b6bcb); + color: #fff; + transition: + transform 0.15s ease, + background 0.2s ease, + box-shadow 0.2s ease; + touch-action: none; + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; +} + +.voice-mic-btn:active { + transform: scale(0.94); +} + +.voice-mic-btn.listening { + background: var(--joy-palette-danger-solidBg, #c41c1c); + box-shadow: 0 4px 18px rgba(196, 28, 28, 0.35); +} + +.voice-pulse-ring { + position: absolute; + inset: 0; + border-radius: 50%; + pointer-events: none; + opacity: 0; +} + +.voice-mic-btn.listening .voice-pulse-ring { + opacity: 1; + animation: voice-pulse 1.8s ease-out infinite; +} + +.voice-mic-btn.listening .voice-pulse-ring:nth-child(2) { + animation-delay: 0.6s; +} + +@keyframes voice-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(196, 28, 28, 0.4); + } + 100% { + box-shadow: 0 0 0 26px rgba(196, 28, 28, 0); + } +} + +/* Faux equalizer shown while listening */ +.voice-eq { + display: flex; + gap: 3px; + align-items: center; + height: 28px; +} + +.voice-eq span { + width: 4px; + border-radius: 2px; + background: var(--joy-palette-danger-solidBg, #c41c1c); + animation: voice-eq-wave 1.1s ease-in-out infinite; +} + +.voice-eq span:nth-child(1) { + animation-delay: 0s; +} +.voice-eq span:nth-child(2) { + animation-delay: 0.18s; +} +.voice-eq span:nth-child(3) { + animation-delay: 0.32s; +} +.voice-eq span:nth-child(4) { + animation-delay: 0.12s; +} +.voice-eq span:nth-child(5) { + animation-delay: 0.26s; +} + +@keyframes voice-eq-wave { + 0%, + 100% { + height: 6px; + } + 50% { + height: 24px; + } +} + +/* Committed task cards slide in as segments are captured */ +.voice-task-card { + animation: voice-card-in 0.25s ease-out; +} + +@keyframes voice-card-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .voice-mic-btn.listening .voice-pulse-ring, + .voice-eq span, + .voice-task-card { + animation: none; + } +} diff --git a/src/views/components/VoiceToTask/VoicePanel.jsx b/src/views/components/VoiceToTask/VoicePanel.jsx new file mode 100644 index 0000000..f271fa6 --- /dev/null +++ b/src/views/components/VoiceToTask/VoicePanel.jsx @@ -0,0 +1,493 @@ +import { + CalendarMonth, + Close, + Flag, + GraphicEq, + Lock, + Mic, + Person, + Repeat, + Sell, + Toll, + WarningAmber, +} from '@mui/icons-material' +import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy' +import moment from 'moment' +import { useMemo, useState } from 'react' +import { parseVoiceTask } from './parseVoiceTask' +import { useVoiceToTask } from './useVoiceToTask' +import './VoicePanel.css' + +const HIGHLIGHT_CLASS = { + repeat: 'highlight-repeat', + priority: 'highlight-priority', + points: 'highlight-points', + assignee: 'highlight-assignee', + label: 'highlight-label', + dueDate: 'highlight-date', +} + +const renderTranscript = (text, highlights) => { + const parts = [] + let lastIndex = 0 + for (const h of highlights) { + if (h.start > lastIndex) parts.push(text.substring(lastIndex, h.start)) + parts.push( + + {text.substring(h.start, h.end)} + , + ) + lastIndex = h.end + } + if (lastIndex < text.length) parts.push(text.substring(lastIndex)) + return parts +} + +const formatDue = dueDate => { + const m = moment(dueDate) + return m.format('HH:mm') === '23:59' + ? m.format('MMM D') + : m.format('MMM D, h:mm A') +} + +const buildChips = (parsed, { members, currentUserId }) => { + const chips = [] + if (parsed.dueDate) { + chips.push({ + key: 'due', + color: 'warning', + icon: , + label: formatDue(parsed.dueDate), + }) + } + if (parsed.frequencyName) { + chips.push({ + key: 'repeat', + color: 'success', + icon: , + label: parsed.frequencyName, + }) + } + if (parsed.priority > 0) { + chips.push({ + key: 'priority', + color: 'danger', + icon: , + label: `P${parsed.priority}`, + }) + } + if (parsed.points != null) { + chips.push({ + key: 'points', + color: 'primary', + icon: , + label: `${parsed.points} pts`, + }) + } + parsed.labelNames.forEach(name => { + chips.push({ + key: `label-${name}`, + color: 'primary', + icon: , + label: name, + }) + }) + if (parsed.isAnyone) { + chips.push({ + key: 'assignee', + color: 'neutral', + icon: , + label: 'Anyone', + }) + } else if ( + parsed.assignees.length > 0 && + parsed.assignees[0].userId !== currentUserId + ) { + const member = members.find(m => m.userId === parsed.assignees[0].userId) + if (member) { + chips.push({ + key: 'assignee', + color: 'neutral', + icon: , + label: member.displayName, + }) + } + } + return chips +} + +const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(segment.text) + + const parsed = useMemo( + () => parseVoiceTask(segment.text, parseCtx), + [segment.text, parseCtx], + ) + const chips = useMemo(() => buildChips(parsed, parseCtx), [parsed, parseCtx]) + + const commitEdit = () => { + setEditing(false) + if (draft.trim() !== segment.text) onUpdate(draft) + } + + return ( + + + {editing ? ( + setDraft(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') commitEdit() + if (e.key === 'Escape') { + setDraft(segment.text) + setEditing(false) + } + }} + onBlur={commitEdit} + sx={{ flex: 1 }} + /> + ) : ( + { + setDraft(segment.text) + setEditing(true) + }} + > + {parsed.title || segment.text} + + )} + + + + + {chips.length > 0 && ( + + {chips.map(chip => ( + + {chip.label} + + ))} + + )} + + ) +} + +/** + * Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal. + * + * Hold the mic to speak, or tap once for hands-free. Pauses and spoken + * separators ("also") split the transcript into task cards. A single captured + * task lands in the smart input for review; multiple tasks are created + * directly from the review list. + */ +const VoicePanel = ({ + open, + userLabels = [], + members = [], + userProfile, + onClose, + onUseSingle, + onCreateMany, +}) => { + const { + phase, + isLocked, + partialText, + segments, + micPressDown, + micPressUp, + startListening, + removeSegment, + updateSegment, + reset, + isNative, + } = useVoiceToTask({ members }) + const [creating, setCreating] = useState(false) + + const parseCtx = useMemo( + () => ({ userLabels, members, currentUserId: userProfile?.id }), + [userLabels, members, userProfile?.id], + ) + + const partialParsed = useMemo( + () => (partialText ? parseVoiceTask(partialText, parseCtx) : null), + [partialText, parseCtx], + ) + + if (!open) return null + + const isListening = phase === 'listening' + const showActions = segments.length > 0 && !isListening && !creating + + const handleCancel = () => { + reset() + onClose() + } + + const handleCreateAll = async () => { + setCreating(true) + try { + await onCreateMany(segments.map(s => parseVoiceTask(s.text, parseCtx))) + } finally { + setCreating(false) + } + } + + const micCaption = isListening + ? isLocked + ? 'Listening — tap to stop' + : 'Release to finish · quick tap locks hands-free' + : segments.length > 0 + ? 'Hold to add another task' + : 'Hold to speak · quick tap for hands-free' + + return ( + + {/* ── Header ── */} + + + Speak your tasks + {isNative && ( + } + sx={{ ml: 'auto' }} + > + On-device + + )} + + + {/* ── Permission denied ── */} + {phase === 'denied' && ( + + + + + Microphone access is needed for voice capture. Enable it in your + device settings and try again. + + + + + )} + + {/* ── Captured task cards ── */} + {segments.length > 0 && ( + + {segments.map(segment => ( + removeSegment(segment.id)} + onUpdate={text => updateSegment(segment.id, text)} + /> + ))} + + )} + + {/* ── Live transcript ── */} + {isListening && ( + + + {partialText ? ( + + {renderTranscript(partialText, partialParsed?.highlights || [])} + + ) : ( + + Listening… + + )} + + + )} + + {/* ── Mic stage ── */} + {phase !== 'denied' && ( + +
+ + + + + +
+ + + {micCaption} + + + Pause or say “also” between tasks · say + “scratch that” to remove the last one + +
+ )} + + {/* ── Footer ── */} + + + + {creating && ( + + )} + {showActions && + (segments.length === 1 ? ( + + ) : ( + + ))} + + +
+ ) +} + +export default VoicePanel diff --git a/src/views/components/VoiceToTask/parseVoiceTask.js b/src/views/components/VoiceToTask/parseVoiceTask.js new file mode 100644 index 0000000..ad46871 --- /dev/null +++ b/src/views/components/VoiceToTask/parseVoiceTask.js @@ -0,0 +1,201 @@ +import * as chrono from 'chrono-node' +import moment from 'moment' +import { isPlusAccount } from '../../../utils/Helpers' +import { generateUUID } from '../../../utils/UUID' +import { + parseAssignees, + parseDueDate, + parseLabels, + parsePoints, + parsePriority, + parseRepeatV2, +} from '../CustomParsers' + +// Pure equivalent of AddTaskModal.processText — parses one sentence into a +// structured task (no state setters), preserving the same parser order and +// sequential-cleanup behavior so voice and typed input stay consistent. + +const mapMembersForParsing = members => + members.map(member => ({ + userId: member.userId, + username: + member.username || member.displayName?.toLowerCase().replace(/\s+/g, ''), + displayName: member.displayName, + name: member.displayName, + id: member.userId, + })) + +// Merge overlapping highlight ranges, higher parser priority wins — same +// resolution rules as AddTaskModal.renderHighlightedSentence. +const resolveHighlights = ({ + repeat, + priority, + points, + assignees, + labels, + dueDate, +}) => { + const all = [] + repeat?.forEach(h => all.push({ ...h, type: 'repeat', rank: 60 })) + priority?.forEach(h => all.push({ ...h, type: 'priority', rank: 50 })) + points?.forEach(h => all.push({ ...h, type: 'points', rank: 45 })) + assignees?.forEach(h => all.push({ ...h, type: 'assignee', rank: 40 })) + labels?.forEach(h => all.push({ ...h, type: 'label', rank: 30 })) + if (dueDate) all.push({ ...dueDate, type: 'dueDate', rank: 20 }) + + all.sort((a, b) => a.start - b.start) + const resolved = [] + for (const current of all) { + const previous = resolved[resolved.length - 1] + if (previous && current.start < previous.end) { + if (current.rank > previous.rank) { + resolved.pop() + resolved.push(current) + } + } else { + resolved.push(current) + } + } + return resolved +} + +export const parseVoiceTask = ( + sentence, + { userLabels = [], members = [], currentUserId = null } = {}, +) => { + const assigneesForParsing = mapMembersForParsing(members) + + const priority = parsePriority(sentence) + const points = parsePoints(sentence) + const labels = parseLabels(sentence, userLabels) + const assigneesResult = parseAssignees(sentence, assigneesForParsing) + const repeat = parseRepeatV2(sentence) + const dueDateParsed = parseDueDate(sentence, chrono) + + // Sequential cleanup — identical chain to AddTaskModal.processText + let cleaned = sentence + if (priority.result) cleaned = priority.cleanedSentence + if (points.result) { + const reparse = parsePoints(cleaned) + if (reparse.result) cleaned = reparse.cleanedSentence + } + if (labels.result) { + const reparse = parseLabels(cleaned, userLabels) + if (reparse.result) cleaned = reparse.cleanedSentence + } + if (assigneesResult.result) { + const reparse = parseAssignees(cleaned, assigneesForParsing) + if (reparse.result) cleaned = reparse.cleanedSentence + } + if (repeat.result) { + const reparse = parseRepeatV2(cleaned) + if (reparse.result) cleaned = reparse.cleanedSentence + } + if (dueDateParsed.result) { + const reparse = parseDueDate(cleaned, chrono) + if (reparse.result) cleaned = reparse.cleanedSentence + } + + let dueDate = null + if (dueDateParsed.result) { + dueDate = moment(dueDateParsed.result).format('YYYY-MM-DDTHH:mm:ss') + } else if (repeat.dueDate) { + dueDate = moment(repeat.dueDate).format('YYYY-MM-DDTHH:mm:ss') + } + + let assignees = [] + const isAnyone = !!assigneesResult.isAnyone + if (!isAnyone) { + if (assigneesResult.result?.length > 0) { + assignees = assigneesResult.result.map(a => ({ userId: a.userId })) + } else if (currentUserId) { + assignees = [{ userId: currentUserId }] + } + } + + const labelIds = (labels.result || []) + .filter(label => label.id) + .map(label => label.id) + + return { + raw: sentence, + title: cleaned.replace(/\s+/g, ' ').trim(), + priority: priority.result ? parseInt(priority.result, 10) : 0, + points: points.result ?? null, + labelIds, + labelNames: (labels.result || []).map(label => label.name), + assignees, + isAnyone, + frequency: repeat.result, + frequencyName: repeat.name, + dueDate, + highlights: resolveHighlights({ + repeat: repeat.highlight, + priority: priority.highlight, + points: points.highlight, + assignees: assigneesResult.highlight, + labels: labels.highlight, + dueDate: dueDateParsed.result ? dueDateParsed.highlight[0] : null, + }), + } +} + +// Builds the same chore payload shape AddTaskModal.createChore submits. +export const buildChorePayload = ( + parsed, + { userProfile, projectId, notificationTemplates }, +) => { + let finalAssignees = parsed.assignees + let finalAssignedTo = null + let finalAssignStrategy = 'keep_last_assigned' + + if (parsed.isAnyone) { + finalAssignees = [] + finalAssignStrategy = 'no_assignee' + } else if (finalAssignees.length === 0) { + finalAssignees = [{ userId: userProfile?.id }] + finalAssignedTo = userProfile?.id + } else { + finalAssignedTo = finalAssignees[0].userId + } + + const chore = { + name: parsed.title, + description: null, + assignees: finalAssignees, + dueDate: parsed.dueDate ? new Date(parsed.dueDate).toISOString() : null, + assignedTo: finalAssignedTo, + assignStrategy: finalAssignStrategy, + isRolling: false, + labelsV2: parsed.labelIds, + priority: parsed.priority || 0, + points: parsed.points ?? null, + deadlineOffset: null, + completionWindow: null, + requireApproval: false, + isPrivate: false, + status: 0, + frequencyType: 'once', + frequencyMetadata: {}, + notificationMetadata: {}, + subTasks: null, + projectId: projectId === 'default' ? null : projectId, + draftId: generateUUID(), + } + + if (parsed.frequency) { + chore.frequencyType = parsed.frequency.frequencyType + chore.frequencyMetadata = parsed.frequency.frequencyMetadata + chore.frequency = parsed.frequency.frequency + if (isPlusAccount(userProfile)) { + chore.notification = true + chore.notificationMetadata = { templates: notificationTemplates } + } + } + if (!parsed.frequency && parsed.dueDate) { + chore.nextDueDate = new Date(parsed.dueDate).toISOString() + chore.notificationMetadata = { templates: notificationTemplates } + } + + return chore +} diff --git a/src/views/components/VoiceToTask/useVoiceToTask.js b/src/views/components/VoiceToTask/useVoiceToTask.js new file mode 100644 index 0000000..45cf0fe --- /dev/null +++ b/src/views/components/VoiceToTask/useVoiceToTask.js @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { voiceInputService } from '../../../service/VoiceInputService' +import { generateUUID } from '../../../utils/UUID' +import { + applyScratchThat, + normalizeSpokenText, + splitSpokenSegments, +} from './voiceNormalizer' + +// Mic gesture: hold = push-to-talk (release stops), quick tap = hands-free +// lock (tap again to stop). In hands-free mode, sustained silence auto-stops +// into review so the user is never stuck watching a live mic. + +const TAP_THRESHOLD_MS = 400 +const HANDS_FREE_SILENCE_STOP_MS = 8000 +const HANDS_FREE_EMPTY_STOP_MS = 20000 + +const haptic = async kind => { + try { + const { Haptics, ImpactStyle, NotificationType } = await import( + '@capacitor/haptics' + ) + if (kind === 'notification') { + await Haptics.notification({ type: NotificationType.Success }) + } else if (kind === 'medium') { + await Haptics.impact({ style: ImpactStyle.Medium }) + } else { + await Haptics.impact({ style: ImpactStyle.Light }) + } + } catch { + // no haptics on this platform + } +} + +// phases: idle | listening | review | denied +export function useVoiceToTask({ members = [] } = {}) { + const [phase, setPhase] = useState('idle') + const [isLocked, setIsLocked] = useState(false) + const [partialText, setPartialText] = useState('') + const [segments, setSegments] = useState([]) + + // Kept in sync manually (not via render) so segment commits that happen + // inside voiceInputService.stop() are visible immediately afterwards. + const segmentsRef = useRef([]) + const membersRef = useRef(members) + membersRef.current = members + + const phaseRef = useRef(phase) + phaseRef.current = phase + const lockedRef = useRef(isLocked) + lockedRef.current = isLocked + + const pressStartedAtRef = useRef(0) + const pressStartedListeningRef = useRef(false) + const lastActivityRef = useRef(0) + const watchdogRef = useRef(null) + + const applySegments = useCallback(next => { + segmentsRef.current = next + setSegments(next) + }, []) + + const commitSegment = useCallback( + rawText => { + const normalized = normalizeSpokenText(rawText, { + members: membersRef.current, + }) + const { text, dropPrevious } = applyScratchThat(normalized) + const pieces = splitSpokenSegments(text) + if (!dropPrevious && pieces.length === 0) return + + let base = segmentsRef.current + if (dropPrevious && base.length > 0) { + base = base.slice(0, -1) + haptic('medium') + } + if (pieces.length > 0) haptic('light') + applySegments([ + ...base, + ...pieces.map(piece => ({ id: generateUUID(), text: piece })), + ]) + }, + [applySegments], + ) + + const stopListening = useCallback(async () => { + if (watchdogRef.current) { + clearInterval(watchdogRef.current) + watchdogRef.current = null + } + await voiceInputService.stop() + setPartialText('') + setIsLocked(false) + // stop() commits any buffered partial synchronously through onSegment, + // so the ref is up to date by the time we read it + setPhase(segmentsRef.current.length > 0 ? 'review' : 'idle') + haptic('light') + }, []) + + const startListening = useCallback(async () => { + const permission = await voiceInputService.requestPermission() + if (permission !== 'granted') { + setPhase('denied') + return false + } + lastActivityRef.current = Date.now() + await voiceInputService.start({ + onPartial: text => { + lastActivityRef.current = Date.now() + setPartialText( + normalizeSpokenText(text, { members: membersRef.current }), + ) + }, + onSegment: commitSegment, + onError: () => { + setPhase('denied') + }, + onStateChange: () => {}, + }) + setPhase('listening') + haptic('medium') + + // Hands-free: auto-stop into review after sustained silence + watchdogRef.current = setInterval(() => { + if (phaseRef.current !== 'listening' || !lockedRef.current) return + const idleFor = Date.now() - lastActivityRef.current + const limit = + segmentsRef.current.length > 0 + ? HANDS_FREE_SILENCE_STOP_MS + : HANDS_FREE_EMPTY_STOP_MS + if (idleFor > limit) { + stopListening() + } + }, 1000) + return true + }, [commitSegment, stopListening]) + + const micPressDown = useCallback(() => { + pressStartedAtRef.current = Date.now() + if (phaseRef.current === 'listening') { + pressStartedListeningRef.current = false + return + } + pressStartedListeningRef.current = true + startListening() + }, [startListening]) + + const micPressUp = useCallback(() => { + const held = Date.now() - pressStartedAtRef.current + if (pressStartedListeningRef.current) { + if (held < TAP_THRESHOLD_MS) { + // Quick tap → hands-free lock + setIsLocked(true) + } else { + // Hold-to-talk → release ends the capture + stopListening() + } + } else if (phaseRef.current === 'listening') { + // Tap while already listening (locked mode) → stop + stopListening() + } + pressStartedListeningRef.current = false + }, [stopListening]) + + const removeSegment = useCallback( + id => { + applySegments(segmentsRef.current.filter(s => s.id !== id)) + }, + [applySegments], + ) + + const updateSegment = useCallback( + (id, text) => { + applySegments( + segmentsRef.current + .map(s => (s.id === id ? { ...s, text: text.trim() } : s)) + .filter(s => s.text), + ) + }, + [applySegments], + ) + + const reset = useCallback(() => { + voiceInputService.stop() + if (watchdogRef.current) { + clearInterval(watchdogRef.current) + watchdogRef.current = null + } + applySegments([]) + setPartialText('') + setIsLocked(false) + setPhase('idle') + }, [applySegments]) + + // Stop the recognizer if the panel unmounts mid-capture + useEffect(() => { + return () => { + voiceInputService.stop() + if (watchdogRef.current) clearInterval(watchdogRef.current) + } + }, []) + + return { + phase, + isLocked, + partialText, + segments, + micPressDown, + micPressUp, + startListening, + stopListening, + removeSegment, + updateSegment, + reset, + isNative: voiceInputService.isNative, + } +} diff --git a/src/views/components/VoiceToTask/voiceNormalizer.js b/src/views/components/VoiceToTask/voiceNormalizer.js new file mode 100644 index 0000000..1fffc8d --- /dev/null +++ b/src/views/components/VoiceToTask/voiceNormalizer.js @@ -0,0 +1,129 @@ +// Deterministic transforms that turn spoken language into the typed syntax +// CustomParsers understands. No LLM — instant, predictable, fully offline. +// +// "label groceries" → "#groceries" +// "assign to Sarah" → "@Sarah" (only when Sarah is a circle member) +// "worth five points" → "*5" +// "p one" / "top priority" → "priority 1" (parsePriority already handles that) + +const FILLER_REGEX = /(?:^|\s)(?:um+|uh+|erm+|hmm+|mmm+)(?=[\s,.!?]|$)[,.]?/gi + +const NUMBER_WORDS = { + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + ten: 10, + fifteen: 15, + twenty: 20, + 'twenty five': 25, + 'twenty-five': 25, + fifty: 50, + hundred: 100, + 'one hundred': 100, +} + +const NUMBER_WORD_PATTERN = Object.keys(NUMBER_WORDS) + // Longest first so "twenty five" wins over "five" + .sort((a, b) => b.length - a.length) + .join('|') + +const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +export const stripFillers = text => + text.replace(FILLER_REGEX, ' ').replace(/\s+/g, ' ').trim() + +const normalizePriority = text => + text + .replace(/\b(?:top|highest)\s+priority\b/gi, 'priority 1') + .replace( + /\bp[\s-]?(one|two|three|four|[1-4])\b/gi, + (_, n) => `priority ${NUMBER_WORDS[n.toLowerCase()] || n}`, + ) + +const normalizePoints = text => + text.replace( + new RegExp( + `\\b(?:worth\\s+)?(\\d+|${NUMBER_WORD_PATTERN})\\s+points?\\b`, + 'gi', + ), + (_, n) => `*${NUMBER_WORDS[n.toLowerCase()] || n} points`, + ) + +const normalizeLabels = text => + text.replace( + /\b(?:with\s+)?(?:hash\s?tag|labell?ed(?:\s+as)?|label|tagged(?:\s+as)?|tag)\s+([\p{L}\p{N}_]+)/giu, + '#$1', + ) + +const normalizeAssignees = (text, members = []) => { + const assignVerb = '(?:assign(?:ed)?\\s+(?:this\\s+|it\\s+)?to|for)' + let out = text.replace( + new RegExp(`\\b${assignVerb}\\s+(?:anyone|anybody|everyone)\\b`, 'gi'), + '@Anyone', + ) + + for (const member of members) { + const displayName = member.displayName + if (!displayName) continue + const firstName = displayName.split(/\s+/)[0] + // Full display name first so "assign to Mo Tarbin" doesn't leave "Tarbin" + const names = [...new Set([displayName, firstName])].filter( + n => n.length > 1, + ) + for (const name of names) { + out = out.replace( + new RegExp(`\\b${assignVerb}\\s+${escapeRegex(name)}\\b`, 'gi'), + `@${displayName}`, + ) + } + } + return out +} + +export const normalizeSpokenText = (text, { members = [] } = {}) => { + let out = stripFillers(text) + out = normalizePriority(out) + out = normalizePoints(out) + out = normalizeLabels(out) + out = normalizeAssignees(out, members) + return out.replace(/\s+/g, ' ').trim() +} + +// ── Multi-task segmentation ───────────────────────────────────────────────── +// A pause (utterance boundary) always splits — that's handled upstream by the +// recognizer. These spoken separators additionally split within one utterance. +// Deliberately conservative: "and then" is NOT a separator ("wash and then +// fold laundry" is one task). + +const SEPARATOR_REGEX = + /\s*\b(?:and\s+also|also|next\s+task|new\s+task|another\s+task)\b[,.]?\s*/gi + +export const splitSpokenSegments = text => + text + .split(SEPARATOR_REGEX) + .map(s => s.trim().replace(/^[,.]\s*/, '')) + .filter(Boolean) + +// ── "Scratch that" correction ─────────────────────────────────────────────── +// Everything spoken before the command dies. If the command opens the +// utterance ("…pause… scratch that"), the previously committed task dies +// instead. Words after the command carry on as the replacement. + +const SCRATCH_REGEX = + /\s*\b(?:(?:scratch|forget|delete|remove|cancel)\s+(?:that|this|it|last(?:\s+one)?)|never\s?mind)\b[,.]?\s*/gi + +export const applyScratchThat = text => { + const parts = text.split(SCRATCH_REGEX) + if (parts.length === 1) { + return { text: text.trim(), dropPrevious: false } + } + const before = parts.slice(0, -1).join(' ').trim() + const after = parts[parts.length - 1].trim() + return { text: after, dropPrevious: before.length === 0 } +} From a133b271d02ab340e514a60e30209719804ec2de Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 20 Jul 2026 01:54:39 -0400 Subject: [PATCH 2/7] ability to parse user and label --- src/views/components/AddTaskModal.jsx | 94 +++++- .../components/VoiceToTask/VoicePanel.jsx | 296 +++++++++++++++--- .../components/VoiceToTask/parseVoiceTask.js | 1 + .../components/VoiceToTask/useVoiceToTask.js | 24 ++ .../components/VoiceToTask/voiceNormalizer.js | 80 ++++- 5 files changed, 436 insertions(+), 59 deletions(-) diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index bda067c..63ec27f 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,6 +1,7 @@ import { Add } from '@mui/icons-material' import { Box, Button, Typography } from '@mui/joy' import { useMediaQuery } from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' import * as chrono from 'chrono-node' import moment from 'moment' import { useCallback, useEffect, useRef, useState } from 'react' @@ -25,6 +26,7 @@ import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { localAIService } from '../../service/LocalAIService' import { voiceInputService } from '../../service/VoiceInputService' +import { CreateLabel } from '../../utils/Fetcher' import { TASK_COLOR } from '../../utils/Colors' import AdvancedOptionsSection, { AdvancedOptionsTrigger, @@ -69,6 +71,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { useCircleMembers() const { isLoading: isProjectsLoading } = useProjects() const createChoreMutation = useCreateChore() + const queryClient = useQueryClient() const { data: userProfile } = useUserProfile() @@ -92,6 +95,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { const richTextEditorRef = useRef(null) const latestRef = useRef({}) + // Picker edits made on a voice task card, applied once after the reparse + // that follows landing the spoken text in the smart input + const pendingVoiceOverridesRef = useRef(null) const [priority, setPriority] = useState(0) const [dueDate, setDueDate] = useState(null) const [description, setDescription] = useState(null) @@ -492,6 +498,28 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { ) setRenderedParts(parts) + + const overrides = pendingVoiceOverridesRef.current + if (overrides) { + pendingVoiceOverridesRef.current = null + if ('priority' in overrides) setPriority(overrides.priority || 0) + if ('frequency' in overrides) setFrequency(overrides.frequency) + if ('labelIds' in overrides) setLabelsV2(overrides.labelIds || []) + if ('assignees' in overrides || 'isAnyone' in overrides) { + setIsAnyoneTask(!!overrides.isAnyone) + setAssignees(overrides.assignees || []) + } + if ('dueDate' in overrides) { + if (overrides.dueDate) { + syncDueDateStates(overrides.dueDate) + } else { + setDueDate(null) + setDueDateOnly(null) + setDueTime(null) + setUseCustomTime(false) + } + } + } }, [userLabels, renderHighlightedSentence, circleMembers, userProfile], ) @@ -595,23 +623,73 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { } } + // Creates labels that were spoken but don't exist yet. Returns a Map of + // lowercase name → label id covering both created and already-existing ones. + const createMissingLabels = async newLabels => { + const resolved = new Map( + (userLabels || []).map(l => [l.name.toLowerCase(), l.id]), + ) + let createdAny = false + for (const label of newLabels) { + const key = label.name.toLowerCase() + if (resolved.has(key)) continue + try { + const resp = await CreateLabel({ + name: label.name, + color: label.color || '#3b82f6', + }) + const data = await resp.json() + const created = data?.res ?? data + if (created?.id) { + resolved.set(key, created.id) + createdAny = true + } + } catch (error) { + console.error('Error creating label:', error) + } + } + if (createdAny) { + queryClient.invalidateQueries({ queryKey: ['labels'] }) + } + return resolved + } + // Single voice-captured task: land it in the smart input so the user - // reviews it with the normal pickers before creating. - const handleVoiceSingle = text => { + // reviews it with the normal pickers before creating. Setting taskText + // (rather than calling processText directly) lets the reparse effect run + // exactly once, consuming any picker overrides from the panel. + const handleVoiceSingle = async (text, overrides = {}) => { setShowVoice(false) - processText(text) + if (Object.keys(overrides).length > 0) { + pendingVoiceOverridesRef.current = overrides + } + setTaskText(text) + const labels = parseLabels(text, userLabels || []) + if (labels.newLabels?.length) { + // Once the labels query refetches, the reparse links them automatically + await createMissingLabels(labels.newLabels) + } } // Multiple voice-captured tasks: they were reviewed as cards in the panel, // so create them all directly. const handleVoiceCreateMany = async parsedTasks => { const notificationTemplates = getDefaultNotification() + const allNewLabels = parsedTasks.flatMap(t => t.newLabels || []) + const labelIdsByName = + allNewLabels.length > 0 ? await createMissingLabels(allNewLabels) : null for (const parsed of parsedTasks) { - const chore = buildChorePayload(parsed, { - userProfile, - projectId, - notificationTemplates, - }) + const extraLabelIds = (parsed.newLabels || []) + .map(nl => labelIdsByName?.get(nl.name.toLowerCase())) + .filter(id => id != null && !parsed.labelIds.includes(id)) + const chore = buildChorePayload( + { ...parsed, labelIds: [...parsed.labelIds, ...extraLabelIds] }, + { + userProfile, + projectId, + notificationTemplates, + }, + ) try { const result = await createChoreMutation.mutateAsync(chore) if (result?._pendingCreate) { diff --git a/src/views/components/VoiceToTask/VoicePanel.jsx b/src/views/components/VoiceToTask/VoicePanel.jsx index f271fa6..b33391a 100644 --- a/src/views/components/VoiceToTask/VoicePanel.jsx +++ b/src/views/components/VoiceToTask/VoicePanel.jsx @@ -1,5 +1,6 @@ import { CalendarMonth, + Check, Close, Flag, GraphicEq, @@ -13,7 +14,13 @@ import { } from '@mui/icons-material' import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy' import moment from 'moment' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { TASK_COLOR } from '../../../utils/Colors' +import AssigneePickerField from '../AssigneePickerField' +import DueDatePickerField from '../DueDatePickerField' +import LabelsPickerField from '../LabelsPickerField' +import PriorityPickerField from '../PriorityPickerField' +import RepeatPickerField from '../RepeatPickerField' import { parseVoiceTask } from './parseVoiceTask' import { useVoiceToTask } from './useVoiceToTask' import './VoicePanel.css' @@ -27,6 +34,22 @@ const HIGHLIGHT_CLASS = { dueDate: 'highlight-date', } +const PRIORITY_COLORS = { + 0: TASK_COLOR.NO_PRIORITY, + 1: TASK_COLOR.PRIORITY_1, + 2: TASK_COLOR.PRIORITY_2, + 3: TASK_COLOR.PRIORITY_3, + 4: TASK_COLOR.PRIORITY_4, +} + +const PRIORITY_LABELS = { + 0: '--', + 1: 'P1', + 2: 'P2', + 3: 'P3', + 4: 'P4', +} + const renderTranscript = (text, highlights) => { const parts = [] let lastIndex = 0 @@ -58,41 +81,62 @@ const formatDue = dueDate => { : m.format('MMM D, h:mm A') } -const buildChips = (parsed, { members, currentUserId }) => { +// Compact description for picker-overridden frequencies where the parser's +// human name no longer applies +const describeFrequency = f => { + if (!f) return null + if (f.frequencyType === 'interval') { + const unit = f.frequencyMetadata?.unit || 'days' + return f.frequency > 1 + ? `Every ${f.frequency} ${unit}` + : `Every ${unit.replace(/s$/, '')}` + } + const names = { + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + yearly: 'Yearly', + days_of_the_week: 'Custom days', + day_of_the_month: 'Monthly', + } + return names[f.frequencyType] || 'Repeats' +} + +const buildChips = (effective, frequencyLabel, { members, currentUserId }) => { const chips = [] - if (parsed.dueDate) { + if (effective.dueDate) { chips.push({ key: 'due', color: 'warning', icon: , - label: formatDue(parsed.dueDate), + label: formatDue(effective.dueDate), }) } - if (parsed.frequencyName) { + if (frequencyLabel) { chips.push({ key: 'repeat', color: 'success', icon: , - label: parsed.frequencyName, + label: frequencyLabel, }) } - if (parsed.priority > 0) { + if (effective.priority > 0) { chips.push({ key: 'priority', color: 'danger', icon: , - label: `P${parsed.priority}`, + label: `P${effective.priority}`, }) } - if (parsed.points != null) { + if (effective.points != null) { chips.push({ key: 'points', color: 'primary', icon: , - label: `${parsed.points} pts`, + label: `${effective.points} pts`, }) } - parsed.labelNames.forEach(name => { + effective.labelNames.forEach(name => { chips.push({ key: `label-${name}`, color: 'primary', @@ -100,7 +144,15 @@ const buildChips = (parsed, { members, currentUserId }) => { label: name, }) }) - if (parsed.isAnyone) { + effective.newLabels.forEach(label => { + chips.push({ + key: `new-label-${label.name}`, + color: 'warning', + icon: , + label: `${label.name} · new`, + }) + }) + if (effective.isAnyone) { chips.push({ key: 'assignee', color: 'neutral', @@ -108,10 +160,10 @@ const buildChips = (parsed, { members, currentUserId }) => { label: 'Anyone', }) } else if ( - parsed.assignees.length > 0 && - parsed.assignees[0].userId !== currentUserId + effective.assignees.length > 0 && + effective.assignees[0].userId !== currentUserId ) { - const member = members.find(m => m.userId === parsed.assignees[0].userId) + const member = members.find(m => m.userId === effective.assignees[0].userId) if (member) { chips.push({ key: 'assignee', @@ -124,18 +176,69 @@ const buildChips = (parsed, { members, currentUserId }) => { return chips } -const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => { - const [editing, setEditing] = useState(false) +const TaskPreviewCard = ({ + segment, + parseCtx, + onRemove, + onUpdate, + onPatch, +}) => { + const [expanded, setExpanded] = useState(false) const [draft, setDraft] = useState(segment.text) + const dueEditRef = useRef(null) const parsed = useMemo( () => parseVoiceTask(segment.text, parseCtx), [segment.text, parseCtx], ) - const chips = useMemo(() => buildChips(parsed, parseCtx), [parsed, parseCtx]) + const overrides = useMemo(() => segment.overrides || {}, [segment.overrides]) + const effective = useMemo( + () => ({ ...parsed, ...overrides }), + [parsed, overrides], + ) - const commitEdit = () => { - setEditing(false) + const frequencyLabel = + 'frequency' in overrides + ? describeFrequency(effective.frequency) + : parsed.frequencyName + const chips = useMemo( + () => buildChips(effective, frequencyLabel, parseCtx), + [effective, frequencyLabel, parseCtx], + ) + + const due = effective.dueDate ? moment(effective.dueDate) : null + const dueDateOnly = due ? due.format('YYYY-MM-DD') : null + const hasCustomTime = !!due && due.format('HH:mm') !== '23:59' + const dueTime = hasCustomTime ? due.format('HH:mm') : null + + // DueDatePickerField's Apply fires date/custom-time/time callbacks in + // sequence; collect them in one microtask so they land as a single patch + const queueDuePatch = patch => { + if (!dueEditRef.current) { + dueEditRef.current = { + date: dueDateOnly, + time: dueTime, + custom: hasCustomTime, + } + queueMicrotask(() => { + const { date, time, custom } = dueEditRef.current + dueEditRef.current = null + if (!date) { + onPatch({ dueDate: null }) + } else { + onPatch({ + dueDate: + custom && time + ? moment(`${date}T${time}`).format('YYYY-MM-DDTHH:mm:00') + : moment(date).endOf('day').format('YYYY-MM-DDTHH:mm:ss'), + }) + } + }) + } + Object.assign(dueEditRef.current, patch) + } + + const commitText = () => { if (draft.trim() !== segment.text) onUpdate(draft) } @@ -145,7 +248,7 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => { sx={{ borderRadius: 'md', border: '1px solid', - borderColor: 'divider', + borderColor: expanded ? 'primary.outlinedBorder' : 'divider', bgcolor: 'background.surface', p: 1.25, display: 'flex', @@ -154,34 +257,45 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => { }} > - {editing ? ( + {expanded ? ( setDraft(e.target.value)} onKeyDown={e => { - if (e.key === 'Enter') commitEdit() - if (e.key === 'Escape') { - setDraft(segment.text) - setEditing(false) - } + if (e.key === 'Enter') commitText() + if (e.key === 'Escape') setDraft(segment.text) }} - onBlur={commitEdit} + onBlur={commitText} sx={{ flex: 1 }} /> ) : ( { setDraft(segment.text) - setEditing(true) + setExpanded(true) }} > {parsed.title || segment.text} )} + {expanded && ( + { + commitText() + setExpanded(false) + }} + sx={{ '--IconButton-size': '28px' }} + > + + + )} { - {chips.length > 0 && ( - + + {!expanded && chips.length > 0 && ( + { + setDraft(segment.text) + setExpanded(true) + }} + > {chips.map(chip => ( { ))} )} + + {expanded && ( + + + queueDuePatch({ date: e.target.value || null }) + } + onDueTimeChange={e => + queueDuePatch({ time: e.target.value || null }) + } + onUseCustomTimeChange={checked => + queueDuePatch({ custom: checked }) + } + onClear={() => onPatch({ dueDate: null })} + /> + onPatch({ frequency: f })} + onClear={() => onPatch({ frequency: null })} + /> + onPatch({ priority: p })} + onClear={() => onPatch({ priority: 0 })} + priorityColors={PRIORITY_COLORS} + priorityLabels={PRIORITY_LABELS} + /> + a.userId)} + isAnyone={effective.isAnyone} + onChange={userIds => { + if (userIds.includes('anyone')) { + onPatch({ isAnyone: true, assignees: [] }) + } else { + onPatch({ + isAnyone: false, + assignees: userIds.map(userId => ({ userId })), + }) + } + }} + onClear={() => onPatch({ isAnyone: false, assignees: [] })} + currentUserId={parseCtx.currentUserId} + members={parseCtx.members} + /> + onPatch({ labelIds: ids })} + onClear={() => onPatch({ labelIds: [] })} + labels={parseCtx.userLabels} + /> + + )} ) } @@ -214,10 +409,10 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => { /** * Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal. * - * Hold the mic to speak, or tap once for hands-free. Pauses and spoken - * separators ("also") split the transcript into task cards. A single captured - * task lands in the smart input for review; multiple tasks are created - * directly from the review list. + * Opens straight into hands-free listening. Pauses and spoken separators + * ("also") split the transcript into task cards; tapping a card opens inline + * pickers whose edits override the parsed values. A single captured task + * lands in the smart input for review; multiple are created directly. */ const VoicePanel = ({ open, @@ -235,13 +430,15 @@ const VoicePanel = ({ segments, micPressDown, micPressUp, - startListening, + startHandsFree, removeSegment, updateSegment, + patchSegment, reset, isNative, } = useVoiceToTask({ members }) const [creating, setCreating] = useState(false) + const autoStartedRef = useRef(false) const parseCtx = useMemo( () => ({ userLabels, members, currentUserId: userProfile?.id }), @@ -253,6 +450,15 @@ const VoicePanel = ({ [partialText, parseCtx], ) + // Start capturing the moment the panel opens — the mic tap that opened it + // is the only tap needed + useEffect(() => { + if (open && !autoStartedRef.current) { + autoStartedRef.current = true + startHandsFree() + } + }, [open, startHandsFree]) + if (!open) return null const isListening = phase === 'listening' @@ -263,10 +469,15 @@ const VoicePanel = ({ onClose() } + const mergedTask = segment => ({ + ...parseVoiceTask(segment.text, parseCtx), + ...(segment.overrides || {}), + }) + const handleCreateAll = async () => { setCreating(true) try { - await onCreateMany(segments.map(s => parseVoiceTask(s.text, parseCtx))) + await onCreateMany(segments.map(mergedTask)) } finally { setCreating(false) } @@ -331,7 +542,7 @@ const VoicePanel = ({ size='sm' variant='outlined' color='neutral' - onClick={startListening} + onClick={startHandsFree} > Try Again @@ -347,7 +558,7 @@ const VoicePanel = ({ display: 'flex', flexDirection: 'column', gap: 0.75, - maxHeight: 260, + maxHeight: 300, overflowY: 'auto', }} > @@ -358,6 +569,7 @@ const VoicePanel = ({ parseCtx={parseCtx} onRemove={() => removeSegment(segment.id)} onUpdate={text => updateSegment(segment.id, text)} + onPatch={patch => patchSegment(segment.id, patch)} /> ))}
@@ -470,7 +682,9 @@ const VoicePanel = ({ size='sm' variant='solid' color='primary' - onClick={() => onUseSingle(segments[0].text)} + onClick={() => + onUseSingle(segments[0].text, segments[0].overrides || {}) + } > Use Task diff --git a/src/views/components/VoiceToTask/parseVoiceTask.js b/src/views/components/VoiceToTask/parseVoiceTask.js index ad46871..7b889ee 100644 --- a/src/views/components/VoiceToTask/parseVoiceTask.js +++ b/src/views/components/VoiceToTask/parseVoiceTask.js @@ -124,6 +124,7 @@ export const parseVoiceTask = ( points: points.result ?? null, labelIds, labelNames: (labels.result || []).map(label => label.name), + newLabels: labels.newLabels || [], assignees, isAnyone, frequency: repeat.result, diff --git a/src/views/components/VoiceToTask/useVoiceToTask.js b/src/views/components/VoiceToTask/useVoiceToTask.js index 45cf0fe..6739a33 100644 --- a/src/views/components/VoiceToTask/useVoiceToTask.js +++ b/src/views/components/VoiceToTask/useVoiceToTask.js @@ -135,6 +135,13 @@ export function useVoiceToTask({ members = [] } = {}) { return true }, [commitSegment, stopListening]) + // One-tap entry: start listening already locked into hands-free mode + const startHandsFree = useCallback(async () => { + if (phaseRef.current === 'listening') return + const ok = await startListening() + if (ok) setIsLocked(true) + }, [startListening]) + const micPressDown = useCallback(() => { pressStartedAtRef.current = Date.now() if (phaseRef.current === 'listening') { @@ -180,6 +187,21 @@ export function useVoiceToTask({ members = [] } = {}) { [applySegments], ) + // Picker edits on a card are stored as overrides that win over whatever a + // re-parse of the spoken text would produce + const patchSegment = useCallback( + (id, patch) => { + applySegments( + segmentsRef.current.map(s => + s.id === id + ? { ...s, overrides: { ...(s.overrides || {}), ...patch } } + : s, + ), + ) + }, + [applySegments], + ) + const reset = useCallback(() => { voiceInputService.stop() if (watchdogRef.current) { @@ -208,9 +230,11 @@ export function useVoiceToTask({ members = [] } = {}) { micPressDown, micPressUp, startListening, + startHandsFree, stopListening, removeSegment, updateSegment, + patchSegment, reset, isNative: voiceInputService.isNative, } diff --git a/src/views/components/VoiceToTask/voiceNormalizer.js b/src/views/components/VoiceToTask/voiceNormalizer.js index 1fffc8d..a7cef88 100644 --- a/src/views/components/VoiceToTask/voiceNormalizer.js +++ b/src/views/components/VoiceToTask/voiceNormalizer.js @@ -61,28 +61,88 @@ const normalizeLabels = text => '#$1', ) +// "assign to Sarah" / "assigned to Sarah" / "assign Sarah" / "for Sarah". +// Speech engines spell names their own way ("Sara" for Sarah) and add +// punctuation, so exact display-name matching alone misses real speech — +// an edit-distance-1 fuzzy pass catches those, but only after an explicit +// assign verb so ordinary words never convert. +const ASSIGN_VERB = '(?:assign(?:ed|ee)?(?:\\s+(?:this|it))?(?:\\s+to)?|for)' +const STRICT_ASSIGN_VERB = '(?:assign(?:ed|ee)?(?:\\s+(?:this|it))?(?:\\s+to)?)' + +const levenshtein = (a, b) => { + if (Math.abs(a.length - b.length) > 1) return 2 + const prev = Array.from({ length: b.length + 1 }, (_, i) => i) + for (let i = 1; i <= a.length; i++) { + let diag = prev[0] + prev[0] = i + for (let j = 1; j <= b.length; j++) { + const tmp = prev[j] + prev[j] = Math.min( + prev[j] + 1, + prev[j - 1] + 1, + diag + (a[i - 1] === b[j - 1] ? 0 : 1), + ) + diag = tmp + } + } + return prev[b.length] +} + +const memberNameVariants = member => + [ + member.displayName, + member.displayName?.split(/\s+/)[0], + member.username, + ].filter(n => n && n.length > 1) + +const findMemberFuzzy = (candidate, members) => { + const c = candidate.toLowerCase() + let close = null + for (const member of members) { + for (const name of memberNameVariants(member)) { + const n = name.toLowerCase() + if (n === c) return member + if (!close && n.length >= 4 && c.length >= 4 && levenshtein(n, c) <= 1) { + close = member + } + } + } + return close +} + const normalizeAssignees = (text, members = []) => { - const assignVerb = '(?:assign(?:ed)?\\s+(?:this\\s+|it\\s+)?to|for)' let out = text.replace( - new RegExp(`\\b${assignVerb}\\s+(?:anyone|anybody|everyone)\\b`, 'gi'), + new RegExp( + `\\b${ASSIGN_VERB}\\s+(?:anyone|anybody|everyone)\\b[,.]?`, + 'gi', + ), '@Anyone', ) + // Exact pass — full display name first so "assign to Mo Tarbin" doesn't + // leave a dangling "Tarbin" for (const member of members) { - const displayName = member.displayName - if (!displayName) continue - const firstName = displayName.split(/\s+/)[0] - // Full display name first so "assign to Mo Tarbin" doesn't leave "Tarbin" - const names = [...new Set([displayName, firstName])].filter( - n => n.length > 1, + if (!member.displayName) continue + const names = [...new Set(memberNameVariants(member))].sort( + (a, b) => b.length - a.length, ) for (const name of names) { out = out.replace( - new RegExp(`\\b${assignVerb}\\s+${escapeRegex(name)}\\b`, 'gi'), - `@${displayName}`, + new RegExp(`\\b${ASSIGN_VERB}\\s+${escapeRegex(name)}\\b[,.]?`, 'gi'), + `@${member.displayName}`, ) } } + + // Fuzzy pass — requires an assign verb (not bare "for") so only clearly + // intended names get corrected + out = out.replace( + new RegExp(`\\b${STRICT_ASSIGN_VERB}\\s+([\\p{L}][\\p{L}'-]*)[,.]?`, 'giu'), + (match, candidate) => { + const member = findMemberFuzzy(candidate, members) + return member ? `@${member.displayName}` : match + }, + ) return out } From 09c3dbbebc6bff7a21cb1041a6bca5a6cfa2b839 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 26 Jul 2026 21:41:56 -0400 Subject: [PATCH 3/7] fix(voice): harden native recognition loop and match labels to existing ones only Add heartbeat/timeout recovery for silently-dead Android recognizer sessions, wait for and prefer the late-arriving final transcript over the interim partial, and guard against duplicate commits when it lands after restart. Bias native recognition toward circle member/label names via contextualStrings so unfamiliar names aren't auto-corrected. Restrict spoken "label X" to existing labels (exact or closest fuzzy match) instead of creating new ones, removing the now-unused label-creation flow from AddTaskModal/VoicePanel/ parseVoiceTask. --- ...r-community+speech-recognition+7.0.1.patch | 466 +++++++++++++++++- src/service/VoiceInputService.js | 232 +++++++-- src/views/components/AddTaskModal.jsx | 64 +-- .../components/VoiceToTask/VoicePanel.jsx | 18 +- .../components/VoiceToTask/parseVoiceTask.js | 1 - .../components/VoiceToTask/useVoiceToTask.js | 110 ++++- .../components/VoiceToTask/voiceNormalizer.js | 107 +++- 7 files changed, 850 insertions(+), 148 deletions(-) diff --git a/patches/@capacitor-community+speech-recognition+7.0.1.patch b/patches/@capacitor-community+speech-recognition+7.0.1.patch index 851fc64..abf9ad3 100644 --- a/patches/@capacitor-community+speech-recognition+7.0.1.patch +++ b/patches/@capacitor-community+speech-recognition+7.0.1.patch @@ -1,23 +1,481 @@ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/results.bin b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/results.bin +new file mode 100644 +index 0000000..7ed749e +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/results.bin +@@ -0,0 +1 @@ ++o/bundleLibRuntimeToDirDebug +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Constants.dex b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Constants.dex +new file mode 100644 +index 0000000..720cd1e +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Constants.dex differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Receiver.dex b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Receiver.dex +new file mode 100644 +index 0000000..5d21223 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/Receiver.dex differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.dex b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.dex +new file mode 100644 +index 0000000..668e631 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.dex differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition.dex b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition.dex +new file mode 100644 +index 0000000..59b8c5b +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/com/getcapacitor/community/speechrecognition/SpeechRecognition.dex differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin +new file mode 100644 +index 0000000..d60cb42 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/3b668565d422d3defdbffac23d2ae7a9/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/results.bin b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/results.bin +new file mode 100644 +index 0000000..0d259dd +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/results.bin +@@ -0,0 +1 @@ ++o/classes +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/transformed/classes/classes_dex/classes.dex b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/transformed/classes/classes_dex/classes.dex +new file mode 100644 +index 0000000..d4d71e5 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/.transforms/a9f31cd11f0b4f2759dee65fdc12b529/transformed/classes/classes_dex/classes.dex differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml +new file mode 100644 +index 0000000..b81ecea +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml +@@ -0,0 +1,15 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json +new file mode 100644 +index 0000000..b3c0af9 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json +@@ -0,0 +1,18 @@ ++{ ++ "version": 3, ++ "artifactType": { ++ "type": "AAPT_FRIENDLY_MERGED_MANIFESTS", ++ "kind": "Directory" ++ }, ++ "applicationId": "com.getcapacitor.community.speechrecognition.speechrecognition", ++ "variantName": "debug", ++ "elements": [ ++ { ++ "type": "SINGLE", ++ "filters": [], ++ "attributes": [], ++ "outputFile": "AndroidManifest.xml" ++ } ++ ], ++ "elementType": "File" ++} +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties +new file mode 100644 +index 0000000..1211b1e +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties +@@ -0,0 +1,6 @@ ++aarFormatVersion=1.0 ++aarMetadataVersion=1.0 ++minCompileSdk=1 ++minCompileSdkExtension=0 ++minAndroidGradlePluginVersion=1.0.0 ++coreLibraryDesugaringEnabled=false +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json +new file mode 100644 +index 0000000..9e26dfe +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json +@@ -0,0 +1 @@ ++{} +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar +new file mode 100644 +index 0000000..ff6ad96 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar +new file mode 100644 +index 0000000..eb6f866 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt +new file mode 100644 +index 0000000..f7ac549 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt +@@ -0,0 +1,3 @@ ++int id webview 0x0 ++int layout bridge_layout_main 0x0 ++int string my_string 0x0 +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_bridge_layout_main.xml.flat b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_bridge_layout_main.xml.flat +new file mode 100644 +index 0000000..183eadd +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/compiled_local_resources/debug/compileDebugLibraryResources/out/layout_bridge_layout_main.xml.flat differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties +new file mode 100644 +index 0000000..4e5a864 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties +@@ -0,0 +1,2 @@ ++#Mon Jul 20 15:42:26 EDT 2026 ++com.getcapacitor.community.speechrecognition.speechrecognition.capacitor-community-speech-recognition-main-6\:/layout/bridge_layout_main.xml=/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/layout/bridge_layout_main.xml +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml +new file mode 100644 +index 0000000..e95bf66 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml +@@ -0,0 +1,4 @@ ++ ++ ++ Just a simple string ++ +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml +new file mode 100644 +index 0000000..3058471 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml +@@ -0,0 +1,2 @@ ++ ++Just a simple string +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugAssets/merger.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugAssets/merger.xml +new file mode 100644 +index 0000000..8c51637 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugAssets/merger.xml +@@ -0,0 +1,2 @@ ++ ++ +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml +new file mode 100644 +index 0000000..472210d +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml +@@ -0,0 +1,2 @@ ++ ++ +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugShaders/merger.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugShaders/merger.xml +new file mode 100644 +index 0000000..6bebd8c +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/incremental/mergeDebugShaders/merger.xml +@@ -0,0 +1,2 @@ ++ ++ +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Constants.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Constants.class +new file mode 100644 +index 0000000..3094bd9 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Constants.class differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Receiver.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Receiver.class +new file mode 100644 +index 0000000..af9be6c +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/Receiver.class differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class +new file mode 100644 +index 0000000..a056329 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition.class +new file mode 100644 +index 0000000..57a50fe +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/getcapacitor/community/speechrecognition/SpeechRecognition.class differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt +new file mode 100644 +index 0000000..80ec5cb +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt +@@ -0,0 +1,5 @@ ++R_DEF: Internal format may change without notice ++local ++id webview ++layout bridge_layout_main ++string my_string +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt +new file mode 100644 +index 0000000..6010dd3 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt +@@ -0,0 +1,21 @@ ++1 ++2 ++4 ++5 ++6 ++7 ++7-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:4:5-71 ++7-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:4:22-68 ++8 ++9 ++9-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:5:5-9:15 ++10 ++10-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:6:7-8:16 ++11 ++11-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:7:9-68 ++11-->/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:7:17-65 ++12 ++13 ++14 ++15 +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml +new file mode 100644 +index 0000000..b81ecea +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml +@@ -0,0 +1,15 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json +new file mode 100644 +index 0000000..0637a08 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json +@@ -0,0 +1 @@ ++[] +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt +new file mode 100644 +index 0000000..08f4ebe +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt +@@ -0,0 +1 @@ ++0 Warning/Error +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/layout/bridge_layout_main.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/layout/bridge_layout_main.xml +new file mode 100644 +index 0000000..56fec15 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/layout/bridge_layout_main.xml +@@ -0,0 +1,15 @@ ++ ++ ++ ++ ++ ++ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/values/values.xml b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/values/values.xml +new file mode 100644 +index 0000000..e95bf66 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/packaged_res/debug/packageDebugResources/values/values.xml +@@ -0,0 +1,4 @@ ++ ++ ++ Just a simple string ++ +\ No newline at end of file +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Constants.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Constants.class +new file mode 100644 +index 0000000..3094bd9 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Constants.class differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Receiver.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Receiver.class +new file mode 100644 +index 0000000..af9be6c +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/Receiver.class differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class +new file mode 100644 +index 0000000..a056329 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition$SpeechRecognitionListener.class differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition.class b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition.class +new file mode 100644 +index 0000000..57a50fe +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/com/getcapacitor/community/speechrecognition/SpeechRecognition.class differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar +new file mode 100644 +index 0000000..c701569 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt +new file mode 100644 +index 0000000..f0ee474 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt +@@ -0,0 +1,4 @@ ++com.getcapacitor.community.speechrecognition.speechrecognition ++id webview ++layout bridge_layout_main ++string my_string +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/outputs/logs/manifest-merger-debug-report.txt b/node_modules/@capacitor-community/speech-recognition/android/build/outputs/logs/manifest-merger-debug-report.txt +new file mode 100644 +index 0000000..102bc47 +--- /dev/null ++++ b/node_modules/@capacitor-community/speech-recognition/android/build/outputs/logs/manifest-merger-debug-report.txt +@@ -0,0 +1,28 @@ ++-- Merging decision tree log --- ++manifest ++ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:2:3-10:14 ++INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:2:3-10:14 ++ package ++ INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml ++ xmlns:android ++ ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:2:13-71 ++uses-permission#android.permission.RECORD_AUDIO ++ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:4:5-71 ++ android:name ++ ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:4:22-68 ++queries ++ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:5:5-9:15 ++intent#action:name:android.speech.RecognitionService ++ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:6:7-8:16 ++action#android.speech.RecognitionService ++ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:7:9-68 ++ android:name ++ ADDED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml:7:17-65 ++uses-sdk ++INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml reason: use-sdk injection requested ++INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml ++INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml ++ android:targetSdkVersion ++ INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml ++ android:minSdkVersion ++ INJECTED from /Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/AndroidManifest.xml +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition$SpeechRecognitionListener.class.uniqueId1 b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition$SpeechRecognitionListener.class.uniqueId1 +new file mode 100644 +index 0000000..dac1e83 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition$SpeechRecognitionListener.class.uniqueId1 differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition.class.uniqueId0 b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition.class.uniqueId0 +new file mode 100644 +index 0000000..836f0ad +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SpeechRecognition.class.uniqueId0 differ +diff --git a/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin +new file mode 100644 +index 0000000..c57ddd3 +Binary files /dev/null and b/node_modules/@capacitor-community/speech-recognition/android/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin differ diff --git a/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java b/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java -index a99ea69..2bea32e 100644 +index a99ea69..5cabbd7 100644 --- a/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java +++ b/node_modules/@capacitor-community/speech-recognition/android/src/main/java/com/getcapacitor/community/speechrecognition/SpeechRecognition.java -@@ -168,6 +168,7 @@ public class SpeechRecognition extends Plugin implements Constants { +@@ -81,13 +81,18 @@ public class SpeechRecognition extends Plugin implements Constants { + String prompt = call.getString("prompt", null); + boolean partialResults = call.getBoolean("partialResults", false); + boolean popup = call.getBoolean("popup", false); +- beginListening(language, maxResults, prompt, partialResults, popup, call); ++ JSArray contextualStrings = call.getArray("contextualStrings", new JSArray()); ++ beginListening(language, maxResults, prompt, partialResults, popup, contextualStrings, call); + } + + @PluginMethod + public void stop(final PluginCall call) { + try { + stopListening(); ++ // This never resolved the call on success, only rejected on ++ // exception — any caller doing `await stop()` (e.g. a JS-side ++ // restart loop) would hang forever waiting on this promise. ++ call.resolve(); + } catch (Exception ex) { + call.reject(ex.getLocalizedMessage()); + } +@@ -157,6 +162,7 @@ public class SpeechRecognition extends Plugin implements Constants { + String prompt, + final boolean partialResults, + boolean showPopup, ++ JSArray contextualStrings, + PluginCall call + ) { + Logger.info(getLogTag(), "Beginning to listen for audible speech"); +@@ -168,6 +174,19 @@ public class SpeechRecognition extends Plugin implements Constants { intent.putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, bridge.getActivity().getPackageName()); intent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, partialResults); intent.putExtra("android.speech.extra.DICTATION_MODE", partialResults); + intent.putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true); ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && contextualStrings != null && contextualStrings.length() > 0) { ++ ArrayList biasingStrings = new ArrayList<>(); ++ for (int i = 0; i < contextualStrings.length(); i++) { ++ String value = contextualStrings.optString(i, null); ++ if (value != null && !value.trim().isEmpty()) { ++ biasingStrings.add(value); ++ } ++ } ++ if (!biasingStrings.isEmpty()) { ++ intent.putStringArrayListExtra(RecognizerIntent.EXTRA_BIASING_STRINGS, biasingStrings); ++ } ++ } if (prompt != null) { intent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt); +@@ -283,6 +302,25 @@ public class SpeechRecognition extends Plugin implements Constants { + SpeechRecognition.this.stopListening(); + String errorMssg = getErrorText(error); + ++ // Unlike onEndOfSpeech, this previously never told the JS side the ++ // session ended — silence commonly ends a session via ERROR_SPEECH_TIMEOUT ++ // / ERROR_NO_MATCH rather than onEndOfSpeech, so without this the caller's ++ // restart loop never fires and listening state gets stuck forever. ++ bridge ++ .getWebView() ++ .post(() -> { ++ try { ++ SpeechRecognition.this.lock.lock(); ++ SpeechRecognition.this.listening(false); ++ ++ JSObject ret = new JSObject(); ++ ret.put("status", "stopped"); ++ SpeechRecognition.this.notifyListeners(LISTENING_EVENT, ret); ++ } finally { ++ SpeechRecognition.this.lock.unlock(); ++ } ++ }); ++ + if (this.call != null) { + call.reject(errorMssg); + } diff --git a/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift b/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift -index 5d1b35b..810277d 100644 +index 5d1b35b..899c7a6 100644 --- a/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift +++ b/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift -@@ -79,6 +79,9 @@ public class SpeechRecognition: CAPPlugin { +@@ -54,6 +54,7 @@ public class SpeechRecognition: CAPPlugin { + let language: String = call.getString("language") ?? "en-US" + let maxResults: Int = call.getInt("maxResults") ?? self.defaultMatches + let partialResults: Bool = call.getBool("partialResults") ?? false ++ let contextualStrings: [String] = call.getArray("contextualStrings", String.self) ?? [] + + if self.recognitionTask != nil { + self.recognitionTask?.cancel() +@@ -79,6 +80,12 @@ public class SpeechRecognition: CAPPlugin { self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() self.recognitionRequest?.shouldReportPartialResults = partialResults ++ if !contextualStrings.isEmpty { ++ self.recognitionRequest?.contextualStrings = contextualStrings ++ } + if #available(iOS 13, *), self.speechRecognizer?.supportsOnDeviceRecognition == true { + self.recognitionRequest?.requiresOnDeviceRecognition = true + } diff --git a/src/service/VoiceInputService.js b/src/service/VoiceInputService.js index f752b17..2383c2f 100644 --- a/src/service/VoiceInputService.js +++ b/src/service/VoiceInputService.js @@ -19,12 +19,63 @@ import { Capacitor } from '@capacitor/core' const SILENCE_COMMIT_MS = 2200 const RESTART_DELAY_MS = 250 +// Defense-in-depth: some Android OEM recognizers can die (e.g. after a speech +// timeout error) without emitting any event at all, which would otherwise +// leave the mic looking "still listening" forever with nothing restarting it. +// If no native event of any kind has arrived in this long, assume the session +// is dead and force a restart even with no pending partial text. +const HEARTBEAT_TIMEOUT_MS = 6000 +// Native recognizers accept a limited vocabulary hint list; keep it small so +// the common names/labels actually get weighted rather than diluted. +const MAX_CONTEXTUAL_STRINGS = 100 +// A known Android build of the plugin never resolved stop()'s promise on +// success — any `await`ed native call here hanging silently would otherwise +// wedge the whole restart loop (and the mic would look stuck "listening" +// forever). Cap every native await so a broken plugin promise can't do that. +const NATIVE_CALL_TIMEOUT_MS = 1500 +// Android forwards both the interim AND the true final transcript (from +// onResults) through the same partialResults event, with the final one +// typically landing a couple hundred ms after the session is reported +// "stopped" — and no flag distinguishes them. The final result is usually +// MORE accurate than the last interim (it benefits from the full-utterance +// language model rather than a streaming guess), which matters most exactly +// on names — the same uncertainty behind "Moutaz" being misheard as +// "Models". So rather than committing immediately and discarding the late +// final as noise, wait this long after a session ends for it to arrive and +// supersede the interim before actually committing. +const FINAL_RESULT_GRACE_MS = 450 +// Safety net for a final result arriving even later than the grace window +// (or a duplicate slipping through some other path) — still not committed as +// a second task if it looks like the same utterance. +const DUPLICATE_GUARD_MS = 3000 +// Below this fraction of shared words, two transcripts are treated as +// different utterances rather than a re-delivery of the same one. +const DUPLICATE_WORD_OVERLAP = 0.6 -const START_OPTIONS = { - language: 'en-US', - maxResults: 1, - partialResults: true, - popup: false, +const withTimeout = (promise, ms) => + Promise.race([promise, new Promise(resolve => setTimeout(resolve, ms))]) + +const normalizeForDupeCheck = text => + text + .trim() + .toLowerCase() + .replace(/[.,!?]/g, '') + +// Word-overlap rather than exact/prefix match: names are exactly the words +// ASR is least confident about (the same uncertainty behind "Moutaz" heard as +// "Models"), so the final transcript commonly comes back with a different +// word around a name than the interim partial that already got committed. +// Requiring every character to match would miss that; requiring most of the +// same words to match still catches it as the same utterance. +const wordOverlapRatio = (a, b) => { + const wordsA = new Set(a.split(/\s+/).filter(Boolean)) + const wordsB = new Set(b.split(/\s+/).filter(Boolean)) + if (wordsA.size === 0 || wordsB.size === 0) return 0 + let shared = 0 + for (const word of wordsA) { + if (wordsB.has(word)) shared++ + } + return shared / Math.max(wordsA.size, wordsB.size) } class VoiceInputService { @@ -33,9 +84,26 @@ class VoiceInputService { this._callbacks = null this._partial = '' this._lastSpeechAt = 0 + this._lastNativeEventAt = 0 this._silenceTimer = null this._restarting = false + this._restartPromise = null this._webRecognition = null + this._contextualStrings = [] + this._lastCommittedText = '' + this._lastCommittedAt = 0 + this._awaitingFinal = false + this._resolveAwaitingFinal = null + } + + _startOptions() { + return { + language: 'en-US', + maxResults: 1, + partialResults: true, + popup: false, + contextualStrings: this._contextualStrings, + } } get isNative() { @@ -78,12 +146,21 @@ class VoiceInputService { } } - async start(callbacks) { + // vocabulary: circle member names + label names, used to bias native + // recognition toward the words that matter most for task capture (iOS + // contextualStrings / Android 13+ EXTRA_BIASING_STRINGS). Without this, an + // unfamiliar name like "Moutaz" can get auto-corrected to a dictionary word. + async start(callbacks, vocabulary = []) { if (this._active) return this._callbacks = callbacks this._active = true this._partial = '' this._lastSpeechAt = Date.now() + this._lastNativeEventAt = Date.now() + this._contextualStrings = [...new Set(vocabulary.filter(Boolean))].slice( + 0, + MAX_CONTEXTUAL_STRINGS, + ) if (this.isNative) { await this._startNative() @@ -103,16 +180,35 @@ class VoiceInputService { clearInterval(this._silenceTimer) this._silenceTimer = null } + // Let any in-flight restart (triggered by a native "stopped" event or the + // heartbeat) finish tearing down first, so it doesn't resurrect a session + // right after the user asked to stop. + if (this._restartPromise) { + await this._restartPromise + } if (this.isNative) { + let SpeechRecognition try { - const { SpeechRecognition } = await import( + ;({ SpeechRecognition } = await import( '@capacitor-community/speech-recognition' - ) - await SpeechRecognition.stop() - await SpeechRecognition.removeAllListeners() + )) + await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS) } catch { // recognizer may already be stopped } + // Wait for a possible late-arriving final result while listeners are + // still attached — removing them first would mean it's never heard. + // Always runs, even if the native stop() call above failed, so we + // never skip committing whatever was captured. + await this._finalizeSegment() + try { + await withTimeout( + SpeechRecognition?.removeAllListeners(), + NATIVE_CALL_TIMEOUT_MS, + ) + } catch { + // non-fatal + } } else if (this._webRecognition) { const rec = this._webRecognition this._webRecognition = null @@ -121,27 +217,67 @@ class VoiceInputService { } catch { // already stopped } + this._commitPartial() + } else { + this._commitPartial() + } + this._callbacks?.onStateChange?.(false) + } + + // Called when a session has ended (or is being torn down for restart) and + // whatever's in `_partial` is ready to become a task — except Android's + // true final transcript, if there is one, is usually still in flight and + // hasn't replaced it yet. Give it a brief window to land first. + async _finalizeSegment() { + if (this._partial.trim() && this.isNative) { + this._awaitingFinal = true + await new Promise(resolve => { + this._resolveAwaitingFinal = resolve + setTimeout(resolve, FINAL_RESULT_GRACE_MS) + }) + this._awaitingFinal = false + this._resolveAwaitingFinal = null } this._commitPartial() - this._callbacks?.onStateChange?.(false) } _commitPartial() { const text = this._partial.trim() this._partial = '' this._callbacks?.onPartial?.('') - if (text) this._callbacks?.onSegment?.(text) + if (text) { + this._lastCommittedText = normalizeForDupeCheck(text) + this._lastCommittedAt = Date.now() + this._callbacks?.onSegment?.(text) + } + } + + // True if `text` looks like a re-delivery of what we just committed (exact + // match, or one is a prefix of the other — covers the final result being a + // trimmed/extended variant of the last partial we already committed on). + _isEchoOfLastCommit(text) { + if (!this._lastCommittedText) return false + if (Date.now() - this._lastCommittedAt > DUPLICATE_GUARD_MS) return false + const a = normalizeForDupeCheck(text) + const b = this._lastCommittedText + if (a === b || a.startsWith(b) || b.startsWith(a)) return true + return wordOverlapRatio(a, b) >= DUPLICATE_WORD_OVERLAP } _checkSilence() { if (!this._active || this._restarting) return - if ( - this._partial.trim() && - Date.now() - this._lastSpeechAt > SILENCE_COMMIT_MS - ) { + const now = Date.now() + if (this._partial.trim() && now - this._lastSpeechAt > SILENCE_COMMIT_MS) { // A pause means the utterance (= task) is complete: cycle the recognizer // so the buffer commits and a fresh session begins. this._restartNative() + return + } + if (now - this._lastNativeEventAt > HEARTBEAT_TIMEOUT_MS) { + // No native event of any kind for too long — the recognizer likely + // died silently (seen on some Android devices/OEMs). Force a restart + // so the mic doesn't sit "listening" forever with nothing happening. + this._restartNative() } } @@ -152,14 +288,33 @@ class VoiceInputService { await SpeechRecognition.removeAllListeners() await SpeechRecognition.addListener('partialResults', ({ matches }) => { + this._lastNativeEventAt = Date.now() const text = matches?.[0] || '' if (!text) return + + if (this._awaitingFinal) { + // This is the true final result we were waiting for — it's usually + // more accurate than the interim it's replacing, so use it and stop + // waiting out the rest of the grace window. + this._partial = text + this._callbacks?.onPartial?.(text) + this._resolveAwaitingFinal?.() + return + } + + if (this._isEchoOfLastCommit(text)) { + // Arrived even later than the grace window (or some other stray + // delivery) — still don't let it look like a fresh spoken segment. + return + } + this._partial = text this._lastSpeechAt = Date.now() this._callbacks?.onPartial?.(text) }) await SpeechRecognition.addListener('listeningState', ({ status }) => { + this._lastNativeEventAt = Date.now() if (status === 'stopped' && this._active && !this._restarting) { // OS ended the session on its own (silence on Android, session limit // on iOS) — commit and start over. @@ -169,34 +324,39 @@ class VoiceInputService { // With partialResults the transcript arrives via listeners; the promise's // resolution/rejection timing differs per platform, so don't rely on it. - SpeechRecognition.start(START_OPTIONS).catch(() => { + SpeechRecognition.start(this._startOptions()).catch(() => { if (this._active && !this._restarting) { this._restartNative() } }) } - async _restartNative() { - if (this._restarting) return + _restartNative() { + if (this._restarting) return this._restartPromise this._restarting = true - try { - const { SpeechRecognition } = await import( - '@capacitor-community/speech-recognition' - ) - try { - await SpeechRecognition.stop() - } catch { - // already stopped - } - this._commitPartial() - // Let the OS recognizer tear down before starting a new session - await new Promise(r => setTimeout(r, RESTART_DELAY_MS)) - if (this._active) { - SpeechRecognition.start(START_OPTIONS).catch(() => {}) - this._lastSpeechAt = Date.now() - } - } finally { + this._restartPromise = this._doRestartNative().finally(() => { this._restarting = false + this._restartPromise = null + }) + return this._restartPromise + } + + async _doRestartNative() { + const { SpeechRecognition } = await import( + '@capacitor-community/speech-recognition' + ) + try { + await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS) + } catch { + // already stopped + } + await this._finalizeSegment() + // Let the OS recognizer tear down before starting a new session + await new Promise(r => setTimeout(r, RESTART_DELAY_MS)) + if (this._active) { + SpeechRecognition.start(this._startOptions()).catch(() => {}) + this._lastSpeechAt = Date.now() + this._lastNativeEventAt = Date.now() } } diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 63ec27f..ef9a402 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,7 +1,6 @@ import { Add } from '@mui/icons-material' import { Box, Button, Typography } from '@mui/joy' import { useMediaQuery } from '@mui/material' -import { useQueryClient } from '@tanstack/react-query' import * as chrono from 'chrono-node' import moment from 'moment' import { useCallback, useEffect, useRef, useState } from 'react' @@ -26,7 +25,6 @@ import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { localAIService } from '../../service/LocalAIService' import { voiceInputService } from '../../service/VoiceInputService' -import { CreateLabel } from '../../utils/Fetcher' import { TASK_COLOR } from '../../utils/Colors' import AdvancedOptionsSection, { AdvancedOptionsTrigger, @@ -71,7 +69,6 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { useCircleMembers() const { isLoading: isProjectsLoading } = useProjects() const createChoreMutation = useCreateChore() - const queryClient = useQueryClient() const { data: userProfile } = useUserProfile() @@ -623,73 +620,26 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { } } - // Creates labels that were spoken but don't exist yet. Returns a Map of - // lowercase name → label id covering both created and already-existing ones. - const createMissingLabels = async newLabels => { - const resolved = new Map( - (userLabels || []).map(l => [l.name.toLowerCase(), l.id]), - ) - let createdAny = false - for (const label of newLabels) { - const key = label.name.toLowerCase() - if (resolved.has(key)) continue - try { - const resp = await CreateLabel({ - name: label.name, - color: label.color || '#3b82f6', - }) - const data = await resp.json() - const created = data?.res ?? data - if (created?.id) { - resolved.set(key, created.id) - createdAny = true - } - } catch (error) { - console.error('Error creating label:', error) - } - } - if (createdAny) { - queryClient.invalidateQueries({ queryKey: ['labels'] }) - } - return resolved - } - // Single voice-captured task: land it in the smart input so the user - // reviews it with the normal pickers before creating. Setting taskText - // (rather than calling processText directly) lets the reparse effect run - // exactly once, consuming any picker overrides from the panel. - const handleVoiceSingle = async (text, overrides = {}) => { + // reviews it with the normal pickers before creating. + const handleVoiceSingle = (text, overrides = {}) => { setShowVoice(false) if (Object.keys(overrides).length > 0) { pendingVoiceOverridesRef.current = overrides } setTaskText(text) - const labels = parseLabels(text, userLabels || []) - if (labels.newLabels?.length) { - // Once the labels query refetches, the reparse links them automatically - await createMissingLabels(labels.newLabels) - } } // Multiple voice-captured tasks: they were reviewed as cards in the panel, // so create them all directly. const handleVoiceCreateMany = async parsedTasks => { const notificationTemplates = getDefaultNotification() - const allNewLabels = parsedTasks.flatMap(t => t.newLabels || []) - const labelIdsByName = - allNewLabels.length > 0 ? await createMissingLabels(allNewLabels) : null for (const parsed of parsedTasks) { - const extraLabelIds = (parsed.newLabels || []) - .map(nl => labelIdsByName?.get(nl.name.toLowerCase())) - .filter(id => id != null && !parsed.labelIds.includes(id)) - const chore = buildChorePayload( - { ...parsed, labelIds: [...parsed.labelIds, ...extraLabelIds] }, - { - userProfile, - projectId, - notificationTemplates, - }, - ) + const chore = buildChorePayload(parsed, { + userProfile, + projectId, + notificationTemplates, + }) try { const result = await createChoreMutation.mutateAsync(chore) if (result?._pendingCreate) { diff --git a/src/views/components/VoiceToTask/VoicePanel.jsx b/src/views/components/VoiceToTask/VoicePanel.jsx index b33391a..97601d2 100644 --- a/src/views/components/VoiceToTask/VoicePanel.jsx +++ b/src/views/components/VoiceToTask/VoicePanel.jsx @@ -144,14 +144,6 @@ const buildChips = (effective, frequencyLabel, { members, currentUserId }) => { label: name, }) }) - effective.newLabels.forEach(label => { - chips.push({ - key: `new-label-${label.name}`, - color: 'warning', - icon: , - label: `${label.name} · new`, - }) - }) if (effective.isAnyone) { chips.push({ key: 'assignee', @@ -436,9 +428,10 @@ const VoicePanel = ({ patchSegment, reset, isNative, - } = useVoiceToTask({ members }) + } = useVoiceToTask({ members, userLabels }) const [creating, setCreating] = useState(false) const autoStartedRef = useRef(false) + const segmentsScrollRef = useRef(null) const parseCtx = useMemo( () => ({ userLabels, members, currentUserId: userProfile?.id }), @@ -459,6 +452,12 @@ const VoicePanel = ({ } }, [open, startHandsFree]) + // Keep the newest captured task visible as more are added + useEffect(() => { + const el = segmentsScrollRef.current + if (el) el.scrollTop = el.scrollHeight + }, [segments.length]) + if (!open) return null const isListening = phase === 'listening' @@ -552,6 +551,7 @@ const VoicePanel = ({ {/* ── Captured task cards ── */} {segments.length > 0 && ( label.name), - newLabels: labels.newLabels || [], assignees, isAnyone, frequency: repeat.result, diff --git a/src/views/components/VoiceToTask/useVoiceToTask.js b/src/views/components/VoiceToTask/useVoiceToTask.js index 6739a33..69352f6 100644 --- a/src/views/components/VoiceToTask/useVoiceToTask.js +++ b/src/views/components/VoiceToTask/useVoiceToTask.js @@ -32,8 +32,19 @@ const haptic = async kind => { } } +// Vocabulary fed to the native recognizer as a biasing hint so unfamiliar +// names/labels aren't auto-corrected to a dictionary word (e.g. "Moutaz" → +// "Models"). Best-effort only — unsupported on iOS <13-without-on-device and +// Android <13, which is why the normalizer also does fuzzy post-matching. +const buildVocabulary = (members, userLabels) => [ + ...members.flatMap(m => + [m.displayName, m.displayName?.split(/\s+/)[0], m.username].filter(Boolean), + ), + ...userLabels.map(l => l.name).filter(Boolean), +] + // phases: idle | listening | review | denied -export function useVoiceToTask({ members = [] } = {}) { +export function useVoiceToTask({ members = [], userLabels = [] } = {}) { const [phase, setPhase] = useState('idle') const [isLocked, setIsLocked] = useState(false) const [partialText, setPartialText] = useState('') @@ -44,6 +55,10 @@ export function useVoiceToTask({ members = [] } = {}) { const segmentsRef = useRef([]) const membersRef = useRef(members) membersRef.current = members + const userLabelsRef = useRef(userLabels) + userLabelsRef.current = userLabels + const vocabularyRef = useRef(buildVocabulary(members, userLabels)) + vocabularyRef.current = buildVocabulary(members, userLabels) const phaseRef = useRef(phase) phaseRef.current = phase @@ -54,6 +69,12 @@ export function useVoiceToTask({ members = [] } = {}) { const pressStartedListeningRef = useRef(false) const lastActivityRef = useRef(0) const watchdogRef = useRef(null) + // While the mic is held (not locked), a mid-hold restart (Android session + // limits, forced silence boundary) shouldn't split into a new task — the + // user is still holding the button, so it's still one entry. This tracks + // which segment is the "active" one for the current hold to merge onto; + // reset to null on release so the *next* hold starts a fresh entry. + const activeHoldSegmentIdRef = useRef(null) const applySegments = useCallback(next => { segmentsRef.current = next @@ -64,6 +85,7 @@ export function useVoiceToTask({ members = [] } = {}) { rawText => { const normalized = normalizeSpokenText(rawText, { members: membersRef.current, + userLabels: userLabelsRef.current, }) const { text, dropPrevious } = applyScratchThat(normalized) const pieces = splitSpokenSegments(text) @@ -71,14 +93,52 @@ export function useVoiceToTask({ members = [] } = {}) { let base = segmentsRef.current if (dropPrevious && base.length > 0) { + const dropped = base[base.length - 1] base = base.slice(0, -1) + if (activeHoldSegmentIdRef.current === dropped.id) { + activeHoldSegmentIdRef.current = null + } haptic('medium') } - if (pieces.length > 0) haptic('light') - applySegments([ - ...base, - ...pieces.map(piece => ({ id: generateUUID(), text: piece })), - ]) + if (pieces.length === 0) { + applySegments(base) + return + } + haptic('light') + + if (!lockedRef.current) { + // Hold-to-talk: the first piece continues the entry already active + // for this hold (if any); only a spoken separator within the same + // commit starts additional new entries. + const activeIndex = base.findIndex( + s => s.id === activeHoldSegmentIdRef.current, + ) + if (activeIndex !== -1) { + const merged = [...base] + merged[activeIndex] = { + ...merged[activeIndex], + text: `${merged[activeIndex].text} ${pieces[0]}`.trim(), + } + const rest = pieces.slice(1).map(piece => ({ + id: generateUUID(), + text: piece, + })) + if (rest.length > 0) { + activeHoldSegmentIdRef.current = rest[rest.length - 1].id + } + applySegments([...merged, ...rest]) + return + } + } + + const newPieces = pieces.map(piece => ({ + id: generateUUID(), + text: piece, + })) + if (!lockedRef.current) { + activeHoldSegmentIdRef.current = newPieces[newPieces.length - 1].id + } + applySegments([...base, ...newPieces]) }, [applySegments], ) @@ -91,6 +151,9 @@ export function useVoiceToTask({ members = [] } = {}) { await voiceInputService.stop() setPartialText('') setIsLocked(false) + // Release ends the current hold — the next hold-press starts a fresh + // entry rather than continuing to merge onto this one + activeHoldSegmentIdRef.current = null // stop() commits any buffered partial synchronously through onSegment, // so the ref is up to date by the time we read it setPhase(segmentsRef.current.length > 0 ? 'review' : 'idle') @@ -104,19 +167,25 @@ export function useVoiceToTask({ members = [] } = {}) { return false } lastActivityRef.current = Date.now() - await voiceInputService.start({ - onPartial: text => { - lastActivityRef.current = Date.now() - setPartialText( - normalizeSpokenText(text, { members: membersRef.current }), - ) + await voiceInputService.start( + { + onPartial: text => { + lastActivityRef.current = Date.now() + setPartialText( + normalizeSpokenText(text, { + members: membersRef.current, + userLabels: userLabelsRef.current, + }), + ) + }, + onSegment: commitSegment, + onError: () => { + setPhase('denied') + }, + onStateChange: () => {}, }, - onSegment: commitSegment, - onError: () => { - setPhase('denied') - }, - onStateChange: () => {}, - }) + vocabularyRef.current, + ) setPhase('listening') haptic('medium') @@ -156,7 +225,9 @@ export function useVoiceToTask({ members = [] } = {}) { const held = Date.now() - pressStartedAtRef.current if (pressStartedListeningRef.current) { if (held < TAP_THRESHOLD_MS) { - // Quick tap → hands-free lock + // Quick tap → hands-free lock; from here on, silence boundaries + // should start new entries again, not merge onto the last one + activeHoldSegmentIdRef.current = null setIsLocked(true) } else { // Hold-to-talk → release ends the capture @@ -211,6 +282,7 @@ export function useVoiceToTask({ members = [] } = {}) { applySegments([]) setPartialText('') setIsLocked(false) + activeHoldSegmentIdRef.current = null setPhase('idle') }, [applySegments]) diff --git a/src/views/components/VoiceToTask/voiceNormalizer.js b/src/views/components/VoiceToTask/voiceNormalizer.js index a7cef88..17c3a44 100644 --- a/src/views/components/VoiceToTask/voiceNormalizer.js +++ b/src/views/components/VoiceToTask/voiceNormalizer.js @@ -1,7 +1,7 @@ // Deterministic transforms that turn spoken language into the typed syntax // CustomParsers understands. No LLM — instant, predictable, fully offline. // -// "label groceries" → "#groceries" +// "label groceries" → "#groceries" (only when it matches an existing label) // "assign to Sarah" → "@Sarah" (only when Sarah is a circle member) // "worth five points" → "*5" // "p one" / "top priority" → "priority 1" (parsePriority already handles that) @@ -55,22 +55,23 @@ const normalizePoints = text => (_, n) => `*${NUMBER_WORDS[n.toLowerCase()] || n} points`, ) -const normalizeLabels = text => - text.replace( - /\b(?:with\s+)?(?:hash\s?tag|labell?ed(?:\s+as)?|label|tagged(?:\s+as)?|tag)\s+([\p{L}\p{N}_]+)/giu, - '#$1', - ) - // "assign to Sarah" / "assigned to Sarah" / "assign Sarah" / "for Sarah". -// Speech engines spell names their own way ("Sara" for Sarah) and add -// punctuation, so exact display-name matching alone misses real speech — -// an edit-distance-1 fuzzy pass catches those, but only after an explicit -// assign verb so ordinary words never convert. +// Speech engines spell names their own way — and worse, can auto-correct an +// unfamiliar name to an unrelated dictionary word entirely ("Moutaz" heard as +// "Models"), which plain edit-distance can't recover (too many edits apart). +// But right after an assign verb, the next word has essentially no other +// legitimate reading — it IS a name — so we take the *relative best* match +// among circle members rather than requiring it to be objectively close. +// A same-first-letter guard keeps this from firing on totally unrelated +// words. Contextual-string biasing in VoiceInputService is the primary +// defense (it can make the recognizer hear "Moutaz" correctly in the first +// place); this is the fallback for when biasing isn't supported or still +// mishears. const ASSIGN_VERB = '(?:assign(?:ed|ee)?(?:\\s+(?:this|it))?(?:\\s+to)?|for)' const STRICT_ASSIGN_VERB = '(?:assign(?:ed|ee)?(?:\\s+(?:this|it))?(?:\\s+to)?)' +const MIN_MATCH_SCORE = 0.2 const levenshtein = (a, b) => { - if (Math.abs(a.length - b.length) > 1) return 2 const prev = Array.from({ length: b.length + 1 }, (_, i) => i) for (let i = 1; i <= a.length; i++) { let diag = prev[0] @@ -88,6 +89,9 @@ const levenshtein = (a, b) => { return prev[b.length] } +const similarity = (a, b) => + 1 - levenshtein(a, b) / Math.max(a.length, b.length) + const memberNameVariants = member => [ member.displayName, @@ -95,19 +99,75 @@ const memberNameVariants = member => member.username, ].filter(n => n && n.length > 1) -const findMemberFuzzy = (candidate, members) => { +// Best-scoring item for `candidate` among `items`, requiring only that it +// beats all others and shares a first letter — not an absolute closeness +// threshold. `getVariants` returns the name strings to compare a given item +// against (e.g. a member's display name/first name/username, or a label's +// name). Shared by assignee and label matching since both face the same +// problem: ASR is least confident on exactly the words that matter here. +const findBestFuzzyMatch = (candidate, items, getVariants) => { const c = candidate.toLowerCase() - let close = null - for (const member of members) { - for (const name of memberNameVariants(member)) { + if (c.length < 3) return null + let best = null + let bestScore = MIN_MATCH_SCORE + for (const item of items) { + for (const name of getVariants(item)) { const n = name.toLowerCase() - if (n === c) return member - if (!close && n.length >= 4 && c.length >= 4 && levenshtein(n, c) <= 1) { - close = member + if (n === c) return item + if (n.length < 3 || n[0] !== c[0]) continue + const score = similarity(n, c) + if (score > bestScore) { + bestScore = score + best = item } } } - return close + return best +} + +const findMemberFuzzy = (candidate, members) => + findBestFuzzyMatch(candidate, members, memberNameVariants) + +// "label groceries" / "tag groceries" / "labeled as groceries" — only ever +// converts to a label that already exists (matched exactly or as the closest +// existing one), never invents a new one. Restricted to single-word label +// names: CustomParsers' hashtag pattern (#([\p{L}\p{N}_]+)) can't span a +// space, so a multi-word label like "Home Maintenance" could never be +// represented as "#Home Maintenance" anyway — same limitation typing it by +// hand would hit. +const LABEL_VERB = + '(?:with\\s+)?(?:hash\\s?tag|labell?ed(?:\\s+as)?|label|tagged(?:\\s+as)?|tag)' + +const normalizeLabels = (text, userLabels = []) => { + const singleWordLabels = userLabels.filter(l => l.name && !/\s/.test(l.name)) + let out = text + + // Exact pass first so a clean spoken match always wins over the fuzzy pass + const byLengthDesc = [...singleWordLabels].sort( + (a, b) => b.name.length - a.name.length, + ) + for (const label of byLengthDesc) { + out = out.replace( + new RegExp( + `\\b${LABEL_VERB}\\s+${escapeRegex(label.name)}\\b[,.]?`, + 'gi', + ), + `#${label.name}`, + ) + } + + // Fuzzy pass — the spoken word after the verb, matched against the closest + // existing single-word label + out = out.replace( + new RegExp(`\\b${LABEL_VERB}\\s+([\\p{L}][\\p{L}'-]*)[,.]?`, 'giu'), + (match, candidate) => { + const label = findBestFuzzyMatch(candidate, singleWordLabels, l => [ + l.name, + ]) + return label ? `#${label.name}` : match + }, + ) + return out } const normalizeAssignees = (text, members = []) => { @@ -146,11 +206,14 @@ const normalizeAssignees = (text, members = []) => { return out } -export const normalizeSpokenText = (text, { members = [] } = {}) => { +export const normalizeSpokenText = ( + text, + { members = [], userLabels = [] } = {}, +) => { let out = stripFillers(text) out = normalizePriority(out) out = normalizePoints(out) - out = normalizeLabels(out) + out = normalizeLabels(out, userLabels) out = normalizeAssignees(out, members) return out.replace(/\s+/g, ' ').trim() } From ea89f90edb6838c0b5d3bb75c5cb16073fc426e9 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 28 Jul 2026 00:17:40 -0400 Subject: [PATCH 4/7] fix label picker keep open wnhe something chosen --- src/views/components/BaseOptionPicker.jsx | 11 ++++++++++- src/views/components/LabelsPickerField.jsx | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/views/components/BaseOptionPicker.jsx b/src/views/components/BaseOptionPicker.jsx index bac0434..c445fd2 100644 --- a/src/views/components/BaseOptionPicker.jsx +++ b/src/views/components/BaseOptionPicker.jsx @@ -24,8 +24,17 @@ const BaseOptionPicker = ({ getTriggerText, onClear, menuFooter, + open: openProp, + onOpenChange, }) => { - const [isOpen, setIsOpen] = useState(false) + const [internalOpen, setInternalOpen] = useState(false) + const isControlled = openProp !== undefined + const isOpen = isControlled ? openProp : internalOpen + const setIsOpen = value => { + if (!isControlled) setInternalOpen(value) + const nextValue = typeof value === 'function' ? value(isOpen) : value + onOpenChange?.(nextValue) + } const buttonRef = useRef(null) useEffect(() => { diff --git a/src/views/components/LabelsPickerField.jsx b/src/views/components/LabelsPickerField.jsx index ff27a4a..499640c 100644 --- a/src/views/components/LabelsPickerField.jsx +++ b/src/views/components/LabelsPickerField.jsx @@ -12,6 +12,7 @@ const LabelsPickerField = ({ emptyDisplay = 'icon-text', }) => { const [createOpen, setCreateOpen] = useState(false) + const [pickerOpen, setPickerOpen] = useState(false) const options = labels.map(label => ({ id: label.id, @@ -27,6 +28,8 @@ const LabelsPickerField = ({ values={values} onValuesChange={onChange} onClear={onClear} + open={pickerOpen} + onOpenChange={setPickerOpen} emptyDisplay={emptyDisplay} emptyLabel='Labels' getItemValue={item => item.id} @@ -55,6 +58,7 @@ const LabelsPickerField = ({ startDecorator={} onClick={e => { e.stopPropagation() + setPickerOpen(false) setCreateOpen(true) }} sx={{ width: '100%', justifyContent: 'flex-start' }} From ce49e4afc6b8d7956155572bd16ae6de31c60887 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 28 Jul 2026 02:02:24 -0400 Subject: [PATCH 5/7] Move cancel button to the modal --- src/views/components/AddTaskModal.jsx | 53 ++++++++--- src/views/components/ScanToTask/ScanPanel.jsx | 6 -- .../components/VoiceToTask/VoicePanel.jsx | 89 ++++++++----------- 3 files changed, 75 insertions(+), 73 deletions(-) diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index ef9a402..75dabc7 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -60,7 +60,7 @@ const getDefaultNotification = () => { return defaultNotification } -const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { +const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => { const { ResponsiveModal } = useResponsiveModal() const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm')) const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text' @@ -136,6 +136,29 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { voiceInputService.isSupported().then(setVoiceAvailable) }, []) + // Quick-capture widget entry points (donetick://chores/add?mode=voice|scan) + // land here: open straight into the requested panel, once per modal open so + // backing out of the panel doesn't bounce the user right back into it. + const appliedInitialModeRef = useRef(false) + useEffect(() => { + if (!isModalOpen) { + appliedInitialModeRef.current = false + return + } + if (appliedInitialModeRef.current) return + + if (initialMode === 'voice') { + // isSupported() resolves async — wait for the answer before deciding. + if (!voiceAvailable) return + appliedInitialModeRef.current = true + setShowVoice(true) + } else if (initialMode === 'scan') { + appliedInitialModeRef.current = true + setScanAutoCapture(true) + setShowScan(true) + } + }, [isModalOpen, initialMode, voiceAvailable]) + // Priority colors const priorityColors = { 0: TASK_COLOR.NO_PRIORITY, @@ -817,18 +840,21 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { /> )} - + {/* Sub-panels (voice/scan) own their own confirm action */} + {!showScan && !showVoice && ( + + )} } > @@ -1177,7 +1203,6 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { userLabels={userLabels || []} members={circleMembers?.res || []} userProfile={userProfile} - onClose={() => setShowVoice(false)} onUseSingle={handleVoiceSingle} onCreateMany={handleVoiceCreateMany} /> diff --git a/src/views/components/ScanToTask/ScanPanel.jsx b/src/views/components/ScanToTask/ScanPanel.jsx index 4732193..be7aa02 100644 --- a/src/views/components/ScanToTask/ScanPanel.jsx +++ b/src/views/components/ScanToTask/ScanPanel.jsx @@ -171,9 +171,6 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur /> - {isNativeScanner ? ( -
)} diff --git a/src/views/components/VoiceToTask/VoicePanel.jsx b/src/views/components/VoiceToTask/VoicePanel.jsx index 97601d2..7d6bae3 100644 --- a/src/views/components/VoiceToTask/VoicePanel.jsx +++ b/src/views/components/VoiceToTask/VoicePanel.jsx @@ -411,7 +411,6 @@ const VoicePanel = ({ userLabels = [], members = [], userProfile, - onClose, onUseSingle, onCreateMany, }) => { @@ -426,7 +425,6 @@ const VoicePanel = ({ removeSegment, updateSegment, patchSegment, - reset, isNative, } = useVoiceToTask({ members, userLabels }) const [creating, setCreating] = useState(false) @@ -463,11 +461,6 @@ const VoicePanel = ({ const isListening = phase === 'listening' const showActions = segments.length > 0 && !isListening && !creating - const handleCancel = () => { - reset() - onClose() - } - const mergedTask = segment => ({ ...parseVoiceTask(segment.text, parseCtx), ...(segment.overrides || {}), @@ -644,62 +637,52 @@ const VoicePanel = ({ level='body-xs' sx={{ opacity: 0.5, px: 2, textAlign: 'center' }} > - Pause or say “also” between tasks · say - “scratch that” to remove the last one + Pause between tasks · say “scratch that” to + remove the last one )} - {/* ── Footer ── */} - - - - {creating && ( + {creating ? ( + ) : segments.length === 1 ? ( + + ) : ( + )} - {showActions && - (segments.length === 1 ? ( - - ) : ( - - ))} - + )} ) } From bb87dc2e147399836a884fa92dc87d7ae40287de Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 28 Jul 2026 02:02:44 -0400 Subject: [PATCH 6/7] Add quick capture widget --- android/app/src/main/AndroidManifest.xml | 11 ++ .../widget/QuickCaptureWidgetProvider.java | 47 ++++++++ .../src/main/res/drawable/ic_widget_mic.xml | 14 +++ .../src/main/res/drawable/ic_widget_scan.xml | 15 +++ .../src/main/res/drawable/widget_tile_bg.xml | 6 + .../main/res/layout/widget_quick_capture.xml | 110 ++++++++++++++++++ android/app/src/main/res/values/strings.xml | 5 + .../res/xml/widget_quick_capture_info.xml | 13 +++ ios/App/DonetickWidget/DonetickWidget.swift | 100 ++++++++++++++++ src/CapacitorListener.js | 17 ++- src/views/Chores/MyChores.jsx | 14 ++- src/views/components/AddTaskModal.jsx | 6 +- 12 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/java/com/donetick/app/widget/QuickCaptureWidgetProvider.java create mode 100644 android/app/src/main/res/drawable/ic_widget_mic.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_scan.xml create mode 100644 android/app/src/main/res/drawable/widget_tile_bg.xml create mode 100644 android/app/src/main/res/layout/widget_quick_capture.xml create mode 100644 android/app/src/main/res/xml/widget_quick_capture_info.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2f15d14..1588034 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -72,6 +72,17 @@ android:name="android.appwidget.provider" android:resource="@xml/widget_people_info" /> + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_widget_scan.xml b/android/app/src/main/res/drawable/ic_widget_scan.xml new file mode 100644 index 0000000..e80c7cf --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_scan.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/widget_tile_bg.xml b/android/app/src/main/res/drawable/widget_tile_bg.xml new file mode 100644 index 0000000..b7cb091 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_tile_bg.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/layout/widget_quick_capture.xml b/android/app/src/main/res/layout/widget_quick_capture.xml new file mode 100644 index 0000000..957c659 --- /dev/null +++ b/android/app/src/main/res/layout/widget_quick_capture.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 507bc36..b7eba15 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -28,6 +28,11 @@ People No circle members yet %1$d today · %2$d this week + Quick Capture + Capture a task in one tap — type it, scan it, or say it. + Type + Scan + Speak Widget options Show everyone\'s tasks Include tasks assigned to other members of your circle. Their avatar appears next to their tasks. diff --git a/android/app/src/main/res/xml/widget_quick_capture_info.xml b/android/app/src/main/res/xml/widget_quick_capture_info.xml new file mode 100644 index 0000000..9238cbb --- /dev/null +++ b/android/app/src/main/res/xml/widget_quick_capture_info.xml @@ -0,0 +1,13 @@ + + diff --git a/ios/App/DonetickWidget/DonetickWidget.swift b/ios/App/DonetickWidget/DonetickWidget.swift index aefa958..f32ef73 100644 --- a/ios/App/DonetickWidget/DonetickWidget.swift +++ b/ios/App/DonetickWidget/DonetickWidget.swift @@ -995,6 +995,105 @@ struct PeopleWidget: Widget { } } +// MARK: - Quick Capture widget + +/// One of the three ways into the add-task flow. Purely a launcher — the +/// destinations are handled in src/CapacitorListener.js. +private struct QuickCaptureAction: Identifiable { + let id: String + let title: String + let systemImage: String + let url: URL? + + static let all: [QuickCaptureAction] = [ + QuickCaptureAction( + id: "type", + title: "Type", + systemImage: "plus", + url: URL(string: "donetick://chores/add") + ), + QuickCaptureAction( + id: "scan", + title: "Scan", + systemImage: "doc.viewfinder", + url: URL(string: "donetick://chores/add?mode=scan") + ), + QuickCaptureAction( + id: "voice", + title: "Speak", + systemImage: "mic.fill", + url: URL(string: "donetick://chores/add?mode=voice") + ), + ] +} + +private struct QuickCaptureTile: View { + let action: QuickCaptureAction + + var body: some View { + let tile = VStack(spacing: 5) { + Image(systemName: action.systemImage) + .font(.system(size: 22, weight: .medium)) + .foregroundColor(Palette.accent) + Text(action.title) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Palette.accent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Palette.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + + if let url = action.url { + Link(destination: url) { tile } + } else { + tile + } + } +} + +struct QuickCaptureEntry: TimelineEntry { + let date: Date +} + +/// Static content — one entry, never reloaded. +struct QuickCaptureProvider: TimelineProvider { + func placeholder(in context: Context) -> QuickCaptureEntry { + QuickCaptureEntry(date: Date()) + } + + func getSnapshot(in context: Context, completion: @escaping (QuickCaptureEntry) -> Void) { + completion(QuickCaptureEntry(date: Date())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + completion(Timeline(entries: [QuickCaptureEntry(date: Date())], policy: .never)) + } +} + +struct QuickCaptureWidgetView: View { + var body: some View { + HStack(spacing: 8) { + ForEach(QuickCaptureAction.all) { action in + QuickCaptureTile(action: action) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct QuickCaptureWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "DonetickQuickCaptureWidget", provider: QuickCaptureProvider()) { _ in + QuickCaptureWidgetView().widgetShell() + } + .configurationDisplayName("Quick Capture") + .description("Capture a task in one tap — type it, scan it, or say it.") + // Medium only: systemSmall gives the whole widget a single tap target, + // which can't carry three separate destinations. + .supportedFamilies([.systemMedium]) + } +} + // MARK: - Bundle @main @@ -1003,5 +1102,6 @@ struct DonetickWidgetBundle: WidgetBundle { TodayWidget() WeekWidget() PeopleWidget() + QuickCaptureWidget() } } diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js index fd8d195..7ab69b3 100644 --- a/src/CapacitorListener.js +++ b/src/CapacitorListener.js @@ -63,9 +63,20 @@ const handleNFCChoreDeepLink = (url, isColdStart) => { const handleUrlOpen = (url, isColdStart = false) => { console.log('[NFC] handleUrlOpen:', url) if (url.startsWith('donetick://chores/add')) { - // Widget "+" button: land on the chore list with the quick-add modal open - // (MyChores watches for the add_task param and consumes it). - routerNavigate('/chores?add_task=1') + // Widget "+" / quick-capture buttons: land on the chore list with the + // quick-add modal open (MyChores watches for the add_task param and + // consumes it). ?mode=scan|voice opens straight into that capture panel. + let mode = null + try { + mode = new URL(url).searchParams.get('mode') + } catch { + // malformed URL — fall back to plain text capture + } + routerNavigate( + mode === 'scan' || mode === 'voice' + ? `/chores?add_task=1&mode=${mode}` + : '/chores?add_task=1', + ) } else if (url.startsWith('donetick://chores/')) { handleNFCChoreDeepLink(url, isColdStart) } else if (url.startsWith('donetick://auth/')) { diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index bc9b546..05a1150 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -97,6 +97,8 @@ const MyChores = () => { const [filteredChores, setFilteredChores] = useState([]) const [choreSections, setChoreSections] = useState([]) const [addTaskModalOpen, setAddTaskModalOpen] = useState(false) + // 'voice' | 'scan' | null — set by the quick-capture widget deep links + const [addTaskInitialMode, setAddTaskInitialMode] = useState(null) const [taskInputFocus, setTaskInputFocus] = useState(0) const searchInputRef = useRef(null) const [searchInputFocus, setSearchInputFocus] = useState(0) @@ -439,14 +441,18 @@ const MyChores = () => { setSelectedProjectWithCache, ]) - // Widget "+" deep link (donetick://chores/add → /chores?add_task=1): - // open the quick-add modal once and strip the param so back/refresh - // doesn't re-trigger it. + // Widget deep links (donetick://chores/add[?mode=scan|voice] → + // /chores?add_task=1[&mode=…]): open the quick-add modal once, in the + // requested capture mode, and strip the params so back/refresh doesn't + // re-trigger it. useEffect(() => { if (searchParams.get('add_task') === '1') { + const mode = searchParams.get('mode') + setAddTaskInitialMode(mode === 'voice' || mode === 'scan' ? mode : null) setAddTaskModalOpen(true) const params = new URLSearchParams(searchParams) params.delete('add_task') + params.delete('mode') setSearchParams(params, { replace: true }) } }, [searchParams, setSearchParams]) @@ -1455,8 +1461,10 @@ const MyChores = () => { autoFocus={taskInputFocus} onChoreUpdate={updateChores} isModalOpen={addTaskModalOpen} + initialMode={addTaskInitialMode} onClose={forceRefresh => { setAddTaskModalOpen(false) + setAddTaskInitialMode(null) if (forceRefresh) { refetchChores() } diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 75dabc7..2030efe 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -147,17 +147,19 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => { } if (appliedInitialModeRef.current) return + // Availability resolves async — wait for the answer before deciding; if it + // never turns true the modal simply stays in plain text mode. if (initialMode === 'voice') { - // isSupported() resolves async — wait for the answer before deciding. if (!voiceAvailable) return appliedInitialModeRef.current = true setShowVoice(true) } else if (initialMode === 'scan') { + if (!llmAvailable) return appliedInitialModeRef.current = true setScanAutoCapture(true) setShowScan(true) } - }, [isModalOpen, initialMode, voiceAvailable]) + }, [isModalOpen, initialMode, voiceAvailable, llmAvailable]) // Priority colors const priorityColors = { From cb71b6074dbed239b86c065716b610a352c4e943 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 28 Jul 2026 02:15:56 -0400 Subject: [PATCH 7/7] Add Previous for the widgets --- .../drawable-nodpi/widget_preview_people.png | Bin 0 -> 23919 bytes .../drawable-nodpi/widget_preview_quick.png | Bin 0 -> 7288 bytes .../drawable-nodpi/widget_preview_today.png | Bin 0 -> 25514 bytes .../drawable-nodpi/widget_preview_week.png | Bin 0 -> 47678 bytes .../res/drawable/widget_preview_avatar.xml | 7 + .../main/res/layout/widget_preview_people.xml | 182 +++++++++++++++++ .../main/res/layout/widget_preview_today.xml | 182 +++++++++++++++++ .../main/res/layout/widget_preview_week.xml | 193 ++++++++++++++++++ android/app/src/main/res/values/strings.xml | 21 ++ .../src/main/res/xml/widget_people_info.xml | 2 + .../res/xml/widget_quick_capture_info.xml | 4 + .../src/main/res/xml/widget_today_info.xml | 2 + .../app/src/main/res/xml/widget_week_info.xml | 2 + 13 files changed, 595 insertions(+) create mode 100644 android/app/src/main/res/drawable-nodpi/widget_preview_people.png create mode 100644 android/app/src/main/res/drawable-nodpi/widget_preview_quick.png create mode 100644 android/app/src/main/res/drawable-nodpi/widget_preview_today.png create mode 100644 android/app/src/main/res/drawable-nodpi/widget_preview_week.png create mode 100644 android/app/src/main/res/drawable/widget_preview_avatar.xml create mode 100644 android/app/src/main/res/layout/widget_preview_people.xml create mode 100644 android/app/src/main/res/layout/widget_preview_today.xml create mode 100644 android/app/src/main/res/layout/widget_preview_week.xml diff --git a/android/app/src/main/res/drawable-nodpi/widget_preview_people.png b/android/app/src/main/res/drawable-nodpi/widget_preview_people.png new file mode 100644 index 0000000000000000000000000000000000000000..b36429e33010b3133cdfd5e984322ebcd3071882 GIT binary patch literal 23919 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-nodpi/widget_preview_today.png b/android/app/src/main/res/drawable-nodpi/widget_preview_today.png new file mode 100644 index 0000000000000000000000000000000000000000..4dd0d7a39e5be683071d0b02cdbb919361f95264 GIT binary patch literal 25514 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 B?8N{8 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-nodpi/widget_preview_week.png b/android/app/src/main/res/drawable-nodpi/widget_preview_week.png new file mode 100644 index 0000000000000000000000000000000000000000..be3739ddbce61fc34a2f74c4c2bf9a247da143c5 GIT binary patch literal 47678 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 + + + + + diff --git a/android/app/src/main/res/layout/widget_preview_people.xml b/android/app/src/main/res/layout/widget_preview_people.xml new file mode 100644 index 0000000..4a0f1ec --- /dev/null +++ b/android/app/src/main/res/layout/widget_preview_people.xml @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_preview_today.xml b/android/app/src/main/res/layout/widget_preview_today.xml new file mode 100644 index 0000000..e8a4c86 --- /dev/null +++ b/android/app/src/main/res/layout/widget_preview_today.xml @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_preview_week.xml b/android/app/src/main/res/layout/widget_preview_week.xml new file mode 100644 index 0000000..218ff5f --- /dev/null +++ b/android/app/src/main/res/layout/widget_preview_week.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b7eba15..856fa5d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -37,4 +37,25 @@ Show everyone\'s tasks Include tasks assigned to other members of your circle. Their avatar appears next to their tasks. Save + + + Updated just now + Take out the bins + Water the plants + Book the vet + 6:00 PM + 8:30 PM + 3 + 7 + 4 + Alex + Sam + Jordan + A + S + J + 2 today · 5 this week + 1 today · 3 this week + 0 today · 2 this week diff --git a/android/app/src/main/res/xml/widget_people_info.xml b/android/app/src/main/res/xml/widget_people_info.xml index f55b9d1..c8cb677 100644 --- a/android/app/src/main/res/xml/widget_people_info.xml +++ b/android/app/src/main/res/xml/widget_people_info.xml @@ -2,6 +2,8 @@ +