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 8287cc7..c1ddb92 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/java/com/donetick/app/widget/QuickCaptureWidgetProvider.java b/android/app/src/main/java/com/donetick/app/widget/QuickCaptureWidgetProvider.java new file mode 100644 index 0000000..162135e --- /dev/null +++ b/android/app/src/main/java/com/donetick/app/widget/QuickCaptureWidgetProvider.java @@ -0,0 +1,47 @@ +package com.donetick.app.widget; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.widget.RemoteViews; + +import com.donetick.app.R; + +/** + * "Quick Capture" home-screen widget: three shortcuts straight into the + * add-task flow (type, scan, speak). Purely a launcher — it shows no task + * data, so it never needs a refresh cycle. + */ +public class QuickCaptureWidgetProvider extends AppWidgetProvider { + // Handled in src/CapacitorListener.js → /chores?add_task=1[&mode=…] + private static final String URI_TYPE = "donetick://chores/add"; + private static final String URI_SCAN = "donetick://chores/add?mode=scan"; + private static final String URI_VOICE = "donetick://chores/add?mode=voice"; + + @Override + public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) { + for (int id : appWidgetIds) { + manager.updateAppWidget(id, build(context)); + } + } + + private static RemoteViews build(Context context) { + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_quick_capture); + views.setOnClickPendingIntent(R.id.quick_type, deepLink(context, 10, URI_TYPE)); + views.setOnClickPendingIntent(R.id.quick_scan, deepLink(context, 11, URI_SCAN)); + views.setOnClickPendingIntent(R.id.quick_voice, deepLink(context, 12, URI_VOICE)); + return views; + } + + private static PendingIntent deepLink(Context context, int requestCode, String uri) { + Intent intent = new Intent(context, com.donetick.app.MainActivity.class); + intent.setAction(Intent.ACTION_VIEW); + // Distinct data per tile so the three PendingIntents aren't collapsed. + intent.setData(Uri.parse(uri)); + return PendingIntent.getActivity(context, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + } +} 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 0000000..b36429e Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/widget_preview_people.png differ diff --git a/android/app/src/main/res/drawable-nodpi/widget_preview_quick.png b/android/app/src/main/res/drawable-nodpi/widget_preview_quick.png new file mode 100644 index 0000000..0af14a3 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/widget_preview_quick.png differ 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 0000000..4dd0d7a Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/widget_preview_today.png differ 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 0000000..be3739d Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/widget_preview_week.png differ diff --git a/android/app/src/main/res/drawable/ic_widget_mic.xml b/android/app/src/main/res/drawable/ic_widget_mic.xml new file mode 100644 index 0000000..ae9b053 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_mic.xml @@ -0,0 +1,14 @@ + + + + + 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_preview_avatar.xml b/android/app/src/main/res/drawable/widget_preview_avatar.xml new file mode 100644 index 0000000..4a0c4f0 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_preview_avatar.xml @@ -0,0 +1,7 @@ + + + + + + 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_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/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..856fa5d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -28,8 +28,34 @@ 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. 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 @@ + + diff --git a/android/app/src/main/res/xml/widget_today_info.xml b/android/app/src/main/res/xml/widget_today_info.xml index c06429d..d405396 100644 --- a/android/app/src/main/res/xml/widget_today_info.xml +++ b/android/app/src/main/res/xml/widget_today_info.xml @@ -2,6 +2,8 @@ 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/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/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 64a29e0..3c20ae8 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): @@ -139,9 +143,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`)" @@ -189,12 +195,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: @@ -229,9 +239,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 @@ -265,6 +277,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 9829046..65b97fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,13 +7,16 @@ "": { "name": "donetick", "version": "1.2.27", + "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 f610997..97120d7 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..abf9ad3 --- /dev/null +++ b/patches/@capacitor-community+speech-recognition+7.0.1.patch @@ -0,0 +1,484 @@ +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..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 +@@ -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..899c7a6 100644 +--- a/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift ++++ b/node_modules/@capacitor-community/speech-recognition/ios/Plugin/Plugin.swift +@@ -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 ++ } + + let inputNode: AVAudioInputNode = self.audioEngine!.inputNode + let format: AVAudioFormat = inputNode.outputFormat(forBus: 0) 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/service/VoiceInputService.js b/src/service/VoiceInputService.js new file mode 100644 index 0000000..2383c2f --- /dev/null +++ b/src/service/VoiceInputService.js @@ -0,0 +1,416 @@ +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 +// 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 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 { + constructor() { + this._active = false + 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() { + 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' + } + } + + // 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() + // 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 + } + // 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 { + ;({ SpeechRecognition } = await import( + '@capacitor-community/speech-recognition' + )) + 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 + try { + rec.stop() + } 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() + } + + _commitPartial() { + const text = this._partial.trim() + this._partial = '' + this._callbacks?.onPartial?.('') + 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 + 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() + } + } + + async _startNative() { + const { SpeechRecognition } = await import( + '@capacitor-community/speech-recognition' + ) + 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. + 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(this._startOptions()).catch(() => { + if (this._active && !this._restarting) { + this._restartNative() + } + }) + } + + _restartNative() { + if (this._restarting) return this._restartPromise + this._restarting = true + 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() + } + } + + _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/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index c0d3294..98955d5 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 bc2daa7..65f6a40 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -26,6 +26,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 LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors' import AdvancedOptionsSection, { AdvancedOptionsTrigger, @@ -41,6 +42,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) { @@ -59,7 +62,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' @@ -104,6 +107,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) @@ -136,12 +142,40 @@ 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) }, []) + // 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 + + // 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') { + 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, llmAvailable]) + // Priority colors const priorityColors = { 0: TASK_COLOR.NO_PRIORITY, @@ -498,6 +532,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], ) @@ -601,9 +657,49 @@ 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, overrides = {}) => { + setShowVoice(false) + if (Object.keys(overrides).length > 0) { + pendingVoiceOverridesRef.current = overrides + } + setTaskText(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) @@ -758,22 +854,25 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { /> )} - + {/* Sub-panels (voice/scan) own their own confirm action */} + {!showScan && !showVoice && ( + + )} } > - {!showScan && ( + {!showScan && !showVoice && ( <> { } : undefined } + onVoiceClick={ + voiceAvailable ? () => setShowVoice(true) : undefined + } placeholder='Type your task...' onChange={text => { setTaskText(text) @@ -1115,6 +1217,17 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { )} + {showVoice && ( + + )} + {showScan && ( { - 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' }} 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/SmartTaskTitleInput.jsx b/src/views/components/SmartTaskTitleInput.jsx index 9558b5f..0c9f12c 100644 --- a/src/views/components/SmartTaskTitleInput.jsx +++ b/src/views/components/SmartTaskTitleInput.jsx @@ -1,4 +1,4 @@ -import { CameraEnhance, PhotoFilter } from '@mui/icons-material' +import { CameraEnhance, Mic, PhotoFilter } from '@mui/icons-material' import { IconButton, Tooltip, useColorScheme } from '@mui/joy' import { useEffect, useRef, useState } from 'react' import AutocompleteDropdown from '../TestView/AutocompleteDropdown' @@ -57,6 +57,7 @@ const SmartTaskTitleInput = ({ isNativeScanner, onScanClick, onPhotoSelected, + onVoiceClick, }) => { const { mode, setMode } = useColorScheme() const titleInputRef = useRef(null) @@ -225,13 +226,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 (
@@ -248,7 +250,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', @@ -299,7 +301,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..7d6bae3 --- /dev/null +++ b/src/views/components/VoiceToTask/VoicePanel.jsx @@ -0,0 +1,690 @@ +import { + CalendarMonth, + Check, + 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 { 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' + +const HIGHLIGHT_CLASS = { + repeat: 'highlight-repeat', + priority: 'highlight-priority', + points: 'highlight-points', + assignee: 'highlight-assignee', + label: 'highlight-label', + 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 + 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') +} + +// 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 (effective.dueDate) { + chips.push({ + key: 'due', + color: 'warning', + icon: , + label: formatDue(effective.dueDate), + }) + } + if (frequencyLabel) { + chips.push({ + key: 'repeat', + color: 'success', + icon: , + label: frequencyLabel, + }) + } + if (effective.priority > 0) { + chips.push({ + key: 'priority', + color: 'danger', + icon: , + label: `P${effective.priority}`, + }) + } + if (effective.points != null) { + chips.push({ + key: 'points', + color: 'primary', + icon: , + label: `${effective.points} pts`, + }) + } + effective.labelNames.forEach(name => { + chips.push({ + key: `label-${name}`, + color: 'primary', + icon: , + label: name, + }) + }) + if (effective.isAnyone) { + chips.push({ + key: 'assignee', + color: 'neutral', + icon: , + label: 'Anyone', + }) + } else if ( + effective.assignees.length > 0 && + effective.assignees[0].userId !== currentUserId + ) { + const member = members.find(m => m.userId === effective.assignees[0].userId) + if (member) { + chips.push({ + key: 'assignee', + color: 'neutral', + icon: , + label: member.displayName, + }) + } + } + return chips +} + +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 overrides = useMemo(() => segment.overrides || {}, [segment.overrides]) + const effective = useMemo( + () => ({ ...parsed, ...overrides }), + [parsed, overrides], + ) + + 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) + } + + return ( + + + {expanded ? ( + setDraft(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') commitText() + if (e.key === 'Escape') setDraft(segment.text) + }} + onBlur={commitText} + sx={{ flex: 1 }} + /> + ) : ( + { + setDraft(segment.text) + setExpanded(true) + }} + > + {parsed.title || segment.text} + + )} + {expanded && ( + { + commitText() + setExpanded(false) + }} + sx={{ '--IconButton-size': '28px' }} + > + + + )} + + + + + + {!expanded && chips.length > 0 && ( + { + setDraft(segment.text) + setExpanded(true) + }} + > + {chips.map(chip => ( + + {chip.label} + + ))} + + )} + + {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} + /> + + )} + + ) +} + +/** + * Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal. + * + * 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, + userLabels = [], + members = [], + userProfile, + onUseSingle, + onCreateMany, +}) => { + const { + phase, + isLocked, + partialText, + segments, + micPressDown, + micPressUp, + startHandsFree, + removeSegment, + updateSegment, + patchSegment, + isNative, + } = useVoiceToTask({ members, userLabels }) + const [creating, setCreating] = useState(false) + const autoStartedRef = useRef(false) + const segmentsScrollRef = useRef(null) + + const parseCtx = useMemo( + () => ({ userLabels, members, currentUserId: userProfile?.id }), + [userLabels, members, userProfile?.id], + ) + + const partialParsed = useMemo( + () => (partialText ? parseVoiceTask(partialText, parseCtx) : null), + [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]) + + // 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' + const showActions = segments.length > 0 && !isListening && !creating + + const mergedTask = segment => ({ + ...parseVoiceTask(segment.text, parseCtx), + ...(segment.overrides || {}), + }) + + const handleCreateAll = async () => { + setCreating(true) + try { + await onCreateMany(segments.map(mergedTask)) + } 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)} + onPatch={patch => patchSegment(segment.id, patch)} + /> + ))} + + )} + + {/* ── Live transcript ── */} + {isListening && ( + + + {partialText ? ( + + {renderTranscript(partialText, partialParsed?.highlights || [])} + + ) : ( + + Listening… + + )} + + + )} + + {/* ── Mic stage ── */} + {phase !== 'denied' && ( + +
+ + + + + +
+ + + {micCaption} + + + Pause between tasks · say “scratch that” to + remove the last one + +
+ )} + + {/* ── Footer — dismissing is the modal's Cancel; this owns confirm only ── */} + {(creating || showActions) && ( + + {creating ? ( + + ) : 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..69352f6 --- /dev/null +++ b/src/views/components/VoiceToTask/useVoiceToTask.js @@ -0,0 +1,313 @@ +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 + } +} + +// 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 = [], userLabels = [] } = {}) { + 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 userLabelsRef = useRef(userLabels) + userLabelsRef.current = userLabels + const vocabularyRef = useRef(buildVocabulary(members, userLabels)) + vocabularyRef.current = buildVocabulary(members, userLabels) + + 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) + // 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 + setSegments(next) + }, []) + + const commitSegment = useCallback( + rawText => { + const normalized = normalizeSpokenText(rawText, { + members: membersRef.current, + userLabels: userLabelsRef.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) { + 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) { + 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], + ) + + const stopListening = useCallback(async () => { + if (watchdogRef.current) { + clearInterval(watchdogRef.current) + watchdogRef.current = null + } + 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') + 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, + userLabels: userLabelsRef.current, + }), + ) + }, + onSegment: commitSegment, + onError: () => { + setPhase('denied') + }, + onStateChange: () => {}, + }, + vocabularyRef.current, + ) + 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]) + + // 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') { + 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; 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 + 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], + ) + + // 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) { + clearInterval(watchdogRef.current) + watchdogRef.current = null + } + applySegments([]) + setPartialText('') + setIsLocked(false) + activeHoldSegmentIdRef.current = null + 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, + 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 new file mode 100644 index 0000000..17c3a44 --- /dev/null +++ b/src/views/components/VoiceToTask/voiceNormalizer.js @@ -0,0 +1,252 @@ +// Deterministic transforms that turn spoken language into the typed syntax +// CustomParsers understands. No LLM — instant, predictable, fully offline. +// +// "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) + +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`, + ) + +// "assign to Sarah" / "assigned to Sarah" / "assign Sarah" / "for Sarah". +// 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) => { + 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 similarity = (a, b) => + 1 - levenshtein(a, b) / Math.max(a.length, b.length) + +const memberNameVariants = member => + [ + member.displayName, + member.displayName?.split(/\s+/)[0], + member.username, + ].filter(n => n && n.length > 1) + +// 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() + 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 item + if (n.length < 3 || n[0] !== c[0]) continue + const score = similarity(n, c) + if (score > bestScore) { + bestScore = score + best = item + } + } + } + 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 = []) => { + let out = text.replace( + 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) { + 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${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 +} + +export const normalizeSpokenText = ( + text, + { members = [], userLabels = [] } = {}, +) => { + let out = stripFillers(text) + out = normalizePriority(out) + out = normalizePoints(out) + out = normalizeLabels(out, userLabels) + 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 } +}