Merge pull request #171 from donetick/support-audio-input

Support audio input
This commit is contained in:
Mohamad Tarbin
2026-07-28 02:31:38 -04:00
committed by GitHub
41 changed files with 3802 additions and 41 deletions

View File

@@ -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')

View File

@@ -72,6 +72,17 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_people_info" />
</receiver>
<receiver
android:name=".widget.QuickCaptureWidgetProvider"
android:exported="false"
android:label="@string/widget_quick_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_quick_capture_info" />
</receiver>
<service
android:name=".widget.WidgetListService"
android:exported="false"
@@ -123,4 +134,13 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NFC" />
<!-- Voice task capture -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Android 11+ package visibility for the system speech recognition service -->
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
</manifest>

View File

@@ -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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_accent">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14z" />
<path
android:fillColor="@android:color/white"
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92H17z" />
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/widget_accent">
<!-- Framing corners plus a scan line: reads as "scan" at widget size. -->
<path
android:fillColor="@android:color/white"
android:pathData="M4,4h5v2H6v3H4V4zM15,4h5v5h-2V6h-3V4zM4,15h2v3h3v2H4V15zM18,15h2v5h-5v-2h3V15z" />
<path
android:fillColor="@android:color/white"
android:pathData="M3,11h18v2H3V11z" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Initials disc stand-in for the picker previews; placed widgets get the
real cached avatars from AvatarCache. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/widget_accent_soft" />
<size android:width="28dp" android:height="28dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Tappable tile on the Quick Capture widget. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widget_accent_soft" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Widget-picker preview for the People widget. See widget_preview_today.xml
for why the real shell can't be previewed directly. Avatars are initials
discs here; placed widgets get the real photos from AvatarCache.
Keep in visual sync with widget_shell.xml + widget_row_person.xml. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="14dp"
android:paddingBottom="10dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@+id/preview_count"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_people_title"
android:textColor="@color/widget_text_primary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/preview_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="@drawable/widget_count_bg"
android:gravity="center"
android:minWidth="22dp"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:text="@string/widget_preview_count_people"
android:textColor="@color/widget_accent"
android:textSize="12sp"
android:textStyle="bold" />
</RelativeLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_updated"
android:textColor="@color/widget_text_secondary"
android:textSize="11sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:minHeight="44dp"
android:orientation="horizontal"
android:paddingTop="5dp"
android:paddingBottom="5dp">
<TextView
android:layout_width="28dp"
android:layout_height="28dp"
android:background="@drawable/widget_preview_avatar"
android:gravity="center"
android:text="@string/widget_preview_initials_1"
android:textColor="@color/widget_accent"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_person_1"
android:textColor="@color/widget_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_preview_counts_1"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="44dp"
android:orientation="horizontal"
android:paddingTop="5dp"
android:paddingBottom="5dp">
<TextView
android:layout_width="28dp"
android:layout_height="28dp"
android:background="@drawable/widget_preview_avatar"
android:gravity="center"
android:text="@string/widget_preview_initials_2"
android:textColor="@color/widget_accent"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_person_2"
android:textColor="@color/widget_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_preview_counts_2"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="44dp"
android:orientation="horizontal"
android:paddingTop="5dp"
android:paddingBottom="5dp">
<TextView
android:layout_width="28dp"
android:layout_height="28dp"
android:background="@drawable/widget_preview_avatar"
android:gravity="center"
android:text="@string/widget_preview_initials_3"
android:textColor="@color/widget_accent"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_person_3"
android:textColor="@color/widget_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_preview_counts_3"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Widget-picker preview for the Today widget. Mirrors widget_shell.xml, but
with literal sample rows: the real shell fills its ListView from a
RemoteViewsFactory, which the picker never binds, so it would render blank.
Keep in visual sync with widget_shell.xml + widget_row_task.xml. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="14dp"
android:paddingBottom="10dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@+id/preview_count"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_today_title"
android:textColor="@color/widget_text_primary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/preview_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toStartOf="@+id/preview_add"
android:background="@drawable/widget_count_bg"
android:gravity="center"
android:minWidth="22dp"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:text="@string/widget_preview_count_today"
android:textColor="@color/widget_accent"
android:textSize="12sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/preview_add"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginStart="6dp"
android:background="@drawable/widget_count_bg"
android:contentDescription="@string/widget_add_task"
android:padding="5dp"
android:src="@drawable/ic_widget_add" />
</RelativeLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_updated"
android:textColor="@color/widget_text_secondary"
android:textSize="11sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:minHeight="40dp"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/widget_ring"
android:tint="@color/widget_overdue" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_task_1"
android:textColor="@color/widget_text_primary"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_meta_overdue"
android:textColor="@color/widget_overdue"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="40dp"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/widget_ring"
android:tint="@color/widget_ring_neutral" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_task_2"
android:textColor="@color/widget_text_primary"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_preview_meta_1"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="40dp"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/widget_ring"
android:tint="@color/widget_ring_neutral" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_task_3"
android:textColor="@color/widget_text_primary"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_preview_meta_2"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,193 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Widget-picker preview for the Next 7 Days widget. See widget_preview_today.xml
for why the real shell can't be previewed directly. Keep in visual sync with
widget_shell.xml + widget_row_day_header.xml + widget_row_task.xml. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="14dp"
android:paddingBottom="10dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@+id/preview_count"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_week_title"
android:textColor="@color/widget_text_primary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/preview_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="@drawable/widget_count_bg"
android:gravity="center"
android:minWidth="22dp"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:text="@string/widget_preview_count_week"
android:textColor="@color/widget_accent"
android:textSize="12sp"
android:textStyle="bold" />
</RelativeLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_updated"
android:textColor="@color/widget_text_secondary"
android:textSize="11sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:letterSpacing="0.08"
android:paddingTop="8dp"
android:paddingBottom="2dp"
android:text="@string/widget_group_today"
android:textAllCaps="true"
android:textColor="@color/widget_text_secondary"
android:textSize="10sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="40dp"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/widget_ring"
android:tint="@color/widget_ring_neutral" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_task_1"
android:textColor="@color/widget_text_primary"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_preview_meta_1"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="40dp"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/widget_ring"
android:tint="@color/widget_ring_neutral" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_task_2"
android:textColor="@color/widget_text_primary"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_preview_meta_2"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:letterSpacing="0.08"
android:paddingTop="8dp"
android:paddingBottom="2dp"
android:text="@string/widget_group_tomorrow"
android:textAllCaps="true"
android:textColor="@color/widget_text_secondary"
android:textSize="10sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="40dp"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/widget_ring"
android:tint="@color/widget_ring_neutral" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_preview_task_3"
android:textColor="@color/widget_text_primary"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_preview_meta_1"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Quick Capture widget: three one-tap ways into the add-task flow. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/quick_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:baselineAligned="false"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/quick_type"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/widget_tile_bg"
android:contentDescription="@string/widget_quick_type"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/widget_quick_type"
android:src="@drawable/ic_widget_add" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:maxLines="1"
android:text="@string/widget_quick_type"
android:textColor="@color/widget_accent"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/quick_scan"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_tile_bg"
android:contentDescription="@string/widget_quick_scan"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/widget_quick_scan"
android:src="@drawable/ic_widget_scan" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:maxLines="1"
android:text="@string/widget_quick_scan"
android:textColor="@color/widget_accent"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/quick_voice"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_tile_bg"
android:contentDescription="@string/widget_quick_voice"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/widget_quick_voice"
android:src="@drawable/ic_widget_mic" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:maxLines="1"
android:text="@string/widget_quick_voice"
android:textColor="@color/widget_accent"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -28,8 +28,34 @@
<string name="widget_people_title">People</string>
<string name="widget_empty_people">No circle members yet</string>
<string name="widget_person_counts">%1$d today · %2$d this week</string>
<string name="widget_quick_label">Quick Capture</string>
<string name="widget_quick_description">Capture a task in one tap — type it, scan it, or say it.</string>
<string name="widget_quick_type">Type</string>
<string name="widget_quick_scan">Scan</string>
<string name="widget_quick_voice">Speak</string>
<string name="widget_config_title">Widget options</string>
<string name="widget_config_include_others">Show everyone\'s tasks</string>
<string name="widget_config_include_others_hint">Include tasks assigned to other members of your circle. Their avatar appears next to their tasks.</string>
<string name="widget_config_save">Save</string>
<!-- Sample data for the widget-picker previews (never shown on a placed
widget). Kept generic so it reads well in any locale. -->
<string name="widget_preview_updated">Updated just now</string>
<string name="widget_preview_task_1">Take out the bins</string>
<string name="widget_preview_task_2">Water the plants</string>
<string name="widget_preview_task_3">Book the vet</string>
<string name="widget_preview_meta_1">6:00 PM</string>
<string name="widget_preview_meta_2">8:30 PM</string>
<string name="widget_preview_count_today">3</string>
<string name="widget_preview_count_week">7</string>
<string name="widget_preview_count_people">4</string>
<string name="widget_preview_person_1">Alex</string>
<string name="widget_preview_person_2">Sam</string>
<string name="widget_preview_person_3">Jordan</string>
<string name="widget_preview_initials_1">A</string>
<string name="widget_preview_initials_2">S</string>
<string name="widget_preview_initials_3">J</string>
<string name="widget_preview_counts_1">2 today · 5 this week</string>
<string name="widget_preview_counts_2">1 today · 3 this week</string>
<string name="widget_preview_counts_3">0 today · 2 this week</string>
</resources>

View File

@@ -2,6 +2,8 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_people_description"
android:initialLayout="@layout/widget_shell"
android:previewLayout="@layout/widget_preview_people"
android:previewImage="@drawable/widget_preview_people"
android:minWidth="180dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The layout is fully static (no bound data), so it doubles as its own
API 31+ preview. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_quick_description"
android:initialLayout="@layout/widget_quick_capture"
android:previewLayout="@layout/widget_quick_capture"
android:previewImage="@drawable/widget_preview_quick"
android:minWidth="180dp"
android:minHeight="70dp"
android:minResizeWidth="180dp"
android:minResizeHeight="60dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />

View File

@@ -2,6 +2,8 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_today_description"
android:initialLayout="@layout/widget_shell"
android:previewLayout="@layout/widget_preview_today"
android:previewImage="@drawable/widget_preview_today"
android:minWidth="180dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"

View File

@@ -2,6 +2,8 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_week_description"
android:initialLayout="@layout/widget_shell"
android:previewLayout="@layout/widget_preview_week"
android:previewImage="@drawable/widget_preview_week"
android:minWidth="250dp"
android:minHeight="180dp"
android:minResizeWidth="180dp"

View File

@@ -2,6 +2,9 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-speech-recognition'
project(':capacitor-community-speech-recognition').projectDir = new File('../node_modules/@capacitor-community/speech-recognition/android')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
@@ -14,6 +17,9 @@ project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-device'
project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capacitor-local-llm'
project(':capacitor-local-llm').projectDir = new File('../node_modules/@capacitor/local-llm/android')

View File

@@ -28,6 +28,10 @@
<string>This app needs access to camera to take photos to attach to task or use as profile photo</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to photo library to select images to attach to task or use as profile photo</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app uses the microphone to let you create tasks by speaking</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>This app uses on-device speech recognition to turn your voice into tasks</string>
<key>UIBackgroundModes</key>
<array/>
<key>UILaunchStoryboardName</key>

View File

@@ -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<QuickCaptureEntry>) -> 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()
}
}

View File

@@ -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'

View File

@@ -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

175
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.getcapacitor.community.speechrecognition.speechrecognition" >
+
+ <uses-sdk android:minSdkVersion="28" />
+
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
+
+ <queries>
+ <intent>
+ <action android:name="android.speech.RecognitionService" />
+ </intent>
+ </queries>
+
+</manifest>
\ 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="my_string">Just a simple string</string>
+</resources>
\ 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<merger version="3"><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main" generated-set="main$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res"><file name="bridge_layout_main" path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res/layout/bridge_layout_main.xml" qualifiers="" type="layout"/><file path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res/values/colors.xml" qualifiers=""/><file path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res/values/styles.xml" qualifiers=""/><file path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/res/values/strings.xml" qualifiers=""><string name="my_string">Just a simple string</string></file></source></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/debug/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug" generated-set="debug$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/debug/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/build/generated/res/resValues/debug"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated" generated-set="generated$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/build/generated/res/resValues/debug"/></dataSet><mergedItems/></merger>
\ 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/assets"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/debug/assets"/></dataSet><dataSet config="generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/build/intermediates/shader_assets/debug/compileDebugShaders/out"/></dataSet></merger>
\ 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/jniLibs"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/debug/jniLibs"/></dataSet></merger>
\ 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/main/shaders"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/mohamad-macbook-air/workspace/temp/dt-frontend-worktrees/support-audio-input/node_modules/@capacitor-community/speech-recognition/android/src/debug/shaders"/></dataSet></merger>
\ 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<?xml version="1.0" encoding="utf-8"?>
+2<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+3 package="com.getcapacitor.community.speechrecognition.speechrecognition" >
+4
+5 <uses-sdk android:minSdkVersion="28" />
+6
+7 <uses-permission android:name="android.permission.RECORD_AUDIO" />
+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 <queries>
+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 <intent>
+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 <action android:name="android.speech.RecognitionService" />
+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 </intent>
+13 </queries>
+14
+15</manifest>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.getcapacitor.community.speechrecognition.speechrecognition" >
+
+ <uses-sdk android:minSdkVersion="28" />
+
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
+
+ <queries>
+ <intent>
+ <action android:name="android.speech.RecognitionService" />
+ </intent>
+ </queries>
+
+</manifest>
\ 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ tools:context="com.getcapacitor.BridgeActivity"
+ >
+
+ <WebView
+ android:id="@+id/webview"
+ android:layout_width="fill_parent"
+ android:layout_height="fill_parent" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="my_string">Just a simple string</string>
+</resources>
\ 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<String> 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)

View File

@@ -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/')) {

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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 }) => {
/>
)}
</Button>
<Button
size='lg'
variant='solid'
color='primary'
disabled={!taskTitle.trim()}
onClick={createChore}
>
Create
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}
</Button>
{/* Sub-panels (voice/scan) own their own confirm action */}
{!showScan && !showVoice && (
<Button
size='lg'
variant='solid'
color='primary'
disabled={!taskTitle.trim()}
onClick={createChore}
>
Create
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}
</Button>
)}
</Box>
}
>
{!showScan && (
{!showScan && !showVoice && (
<>
<Box>
<Box
@@ -850,6 +949,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
}
: undefined
}
onVoiceClick={
voiceAvailable ? () => setShowVoice(true) : undefined
}
placeholder='Type your task...'
onChange={text => {
setTaskText(text)
@@ -1115,6 +1217,17 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
</>
)}
{showVoice && (
<VoicePanel
open
userLabels={userLabels || []}
members={circleMembers?.res || []}
userProfile={userProfile}
onUseSingle={handleVoiceSingle}
onCreateMany={handleVoiceCreateMany}
/>
)}
{showScan && (
<ScanPanel
open

View File

@@ -24,8 +24,17 @@ const BaseOptionPicker = ({
getTriggerText,
onClear,
menuFooter,
open: openProp,
onOpenChange,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [internalOpen, setInternalOpen] = useState(false)
const isControlled = openProp !== undefined
const isOpen = isControlled ? openProp : internalOpen
const setIsOpen = value => {
if (!isControlled) setInternalOpen(value)
const nextValue = typeof value === 'function' ? value(isOpen) : value
onOpenChange?.(nextValue)
}
const buttonRef = useRef(null)
useEffect(() => {

View File

@@ -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={<Add sx={{ fontSize: '16px' }} />}
onClick={e => {
e.stopPropagation()
setPickerOpen(false)
setCreateOpen(true)
}}
sx={{ width: '100%', justifyContent: 'flex-start' }}

View File

@@ -171,9 +171,6 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
/>
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
Cancel
</Button>
{isNativeScanner ? (
<Button
size='sm'
@@ -297,9 +294,6 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
>
Retake
</Button>
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
Cancel
</Button>
</Box>
</Box>
)}

View File

@@ -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 (
<div>
@@ -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 */}
&#8203;
</div>
{showNativeButtons && (
{showActionButtons && (
<span
style={{
position: 'absolute',
@@ -311,7 +313,7 @@ const SmartTaskTitleInput = ({
gap: '0.1rem',
}}
>
{onPhotoSelected && (
{showPhotoButtons && onPhotoSelected && (
<>
<Tooltip title='Select photo' placement='top' size='sm'>
<IconButton
@@ -333,7 +335,7 @@ const SmartTaskTitleInput = ({
/>
</>
)}
{onScanClick && (
{showPhotoButtons && onScanClick && (
<Tooltip title='Scan to create task' placement='top' size='sm'>
<IconButton
size='sm'
@@ -346,6 +348,19 @@ const SmartTaskTitleInput = ({
</IconButton>
</Tooltip>
)}
{showVoiceButton && (
<Tooltip title='Speak to create tasks' placement='top' size='sm'>
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={onVoiceClick}
sx={{ borderRadius: 'xl' }}
>
<Mic fontSize='small' />
</IconButton>
</Tooltip>
)}
</span>
)}
</div>

View File

@@ -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;
}
}

View File

@@ -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(
<span
key={h.start}
className={HIGHLIGHT_CLASS[h.type]}
style={{
textDecoration: 'underline',
textDecorationThickness: '2px',
textDecorationStyle: 'dashed',
}}
>
{text.substring(h.start, h.end)}
</span>,
)
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: <CalendarMonth sx={{ fontSize: 12 }} />,
label: formatDue(effective.dueDate),
})
}
if (frequencyLabel) {
chips.push({
key: 'repeat',
color: 'success',
icon: <Repeat sx={{ fontSize: 12 }} />,
label: frequencyLabel,
})
}
if (effective.priority > 0) {
chips.push({
key: 'priority',
color: 'danger',
icon: <Flag sx={{ fontSize: 12 }} />,
label: `P${effective.priority}`,
})
}
if (effective.points != null) {
chips.push({
key: 'points',
color: 'primary',
icon: <Toll sx={{ fontSize: 12 }} />,
label: `${effective.points} pts`,
})
}
effective.labelNames.forEach(name => {
chips.push({
key: `label-${name}`,
color: 'primary',
icon: <Sell sx={{ fontSize: 12 }} />,
label: name,
})
})
if (effective.isAnyone) {
chips.push({
key: 'assignee',
color: 'neutral',
icon: <Person sx={{ fontSize: 12 }} />,
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: <Person sx={{ fontSize: 12 }} />,
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 (
<Box
className='voice-task-card'
sx={{
borderRadius: 'md',
border: '1px solid',
borderColor: expanded ? 'primary.outlinedBorder' : 'divider',
bgcolor: 'background.surface',
p: 1.25,
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
{expanded ? (
<Input
size='sm'
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') commitText()
if (e.key === 'Escape') setDraft(segment.text)
}}
onBlur={commitText}
sx={{ flex: 1 }}
/>
) : (
<Typography
level='title-sm'
sx={{ flex: 1, cursor: 'pointer', wordBreak: 'break-word' }}
onClick={() => {
setDraft(segment.text)
setExpanded(true)
}}
>
{parsed.title || segment.text}
</Typography>
)}
{expanded && (
<IconButton
size='sm'
variant='soft'
color='primary'
onClick={() => {
commitText()
setExpanded(false)
}}
sx={{ '--IconButton-size': '28px' }}
>
<Check fontSize='small' />
</IconButton>
)}
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={onRemove}
sx={{ '--IconButton-size': '28px' }}
>
<Close fontSize='small' />
</IconButton>
</Box>
{!expanded && chips.length > 0 && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
cursor: 'pointer',
}}
onClick={() => {
setDraft(segment.text)
setExpanded(true)
}}
>
{chips.map(chip => (
<Chip
key={chip.key}
size='sm'
variant='soft'
color={chip.color}
startDecorator={chip.icon}
>
{chip.label}
</Chip>
))}
</Box>
)}
{expanded && (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
overflowX: 'auto',
pt: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
}}
>
<DueDatePickerField
emptyDisplay='icon'
dueDateOnly={dueDateOnly}
dueTime={dueTime}
useCustomTime={hasCustomTime}
onDueDateChange={e =>
queueDuePatch({ date: e.target.value || null })
}
onDueTimeChange={e =>
queueDuePatch({ time: e.target.value || null })
}
onUseCustomTimeChange={checked =>
queueDuePatch({ custom: checked })
}
onClear={() => onPatch({ dueDate: null })}
/>
<RepeatPickerField
emptyDisplay='icon'
value={effective.frequency}
onChange={f => onPatch({ frequency: f })}
onClear={() => onPatch({ frequency: null })}
/>
<PriorityPickerField
emptyDisplay='icon'
value={effective.priority}
onChange={p => onPatch({ priority: p })}
onClear={() => onPatch({ priority: 0 })}
priorityColors={PRIORITY_COLORS}
priorityLabels={PRIORITY_LABELS}
/>
<AssigneePickerField
emptyDisplay='icon'
values={effective.assignees.map(a => 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}
/>
<LabelsPickerField
emptyDisplay='icon'
values={effective.labelIds}
onChange={ids => onPatch({ labelIds: ids })}
onClear={() => onPatch({ labelIds: [] })}
labels={parseCtx.userLabels}
/>
</Box>
)}
</Box>
)
}
/**
* 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 (
<Box
sx={{
borderRadius: 'md',
border: '1px solid',
borderColor: 'primary.outlinedBorder',
overflow: 'hidden',
bgcolor: 'background.level1',
}}
>
{/* ── Header ── */}
<Box
sx={{
px: 1.5,
pt: 1.25,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<GraphicEq color='primary' fontSize='small' />
<Typography level='title-sm'>Speak your tasks</Typography>
{isNative && (
<Chip
size='sm'
variant='soft'
color='success'
startDecorator={<Lock sx={{ fontSize: 12 }} />}
sx={{ ml: 'auto' }}
>
On-device
</Chip>
)}
</Box>
{/* ── Permission denied ── */}
{phase === 'denied' && (
<Box sx={{ p: 2 }}>
<Box
sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1.5 }}
>
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
<Typography level='body-sm'>
Microphone access is needed for voice capture. Enable it in your
device settings and try again.
</Typography>
</Box>
<Button
size='sm'
variant='outlined'
color='neutral'
onClick={startHandsFree}
>
Try Again
</Button>
</Box>
)}
{/* ── Captured task cards ── */}
{segments.length > 0 && (
<Box
ref={segmentsScrollRef}
sx={{
px: 1.5,
pt: 1.25,
display: 'flex',
flexDirection: 'column',
gap: 0.75,
maxHeight: 300,
overflowY: 'auto',
}}
>
{segments.map(segment => (
<TaskPreviewCard
key={segment.id}
segment={segment}
parseCtx={parseCtx}
onRemove={() => removeSegment(segment.id)}
onUpdate={text => updateSegment(segment.id, text)}
onPatch={patch => patchSegment(segment.id, patch)}
/>
))}
</Box>
)}
{/* ── Live transcript ── */}
{isListening && (
<Box sx={{ px: 1.5, pt: 1.25 }}>
<Box
sx={{
minHeight: 44,
borderRadius: 'md',
border: '1px dashed',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
px: 1.25,
py: 1,
}}
>
{partialText ? (
<Typography level='body-md' sx={{ wordBreak: 'break-word' }}>
{renderTranscript(partialText, partialParsed?.highlights || [])}
</Typography>
) : (
<Typography level='body-sm' sx={{ opacity: 0.5 }}>
Listening
</Typography>
)}
</Box>
</Box>
)}
{/* ── Mic stage ── */}
{phase !== 'denied' && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
py: 2,
gap: 1.25,
}}
>
<div className='voice-eq' style={{ opacity: isListening ? 1 : 0 }}>
<span />
<span />
<span />
<span />
<span />
</div>
<button
type='button'
aria-label={isListening ? 'Stop listening' : 'Start voice capture'}
className={`voice-mic-btn${isListening ? ' listening' : ''}`}
onPointerDown={e => {
e.preventDefault()
e.currentTarget.setPointerCapture?.(e.pointerId)
micPressDown()
}}
onPointerUp={micPressUp}
onPointerCancel={micPressUp}
onContextMenu={e => e.preventDefault()}
>
<span className='voice-pulse-ring' />
<span className='voice-pulse-ring' />
<Mic sx={{ fontSize: 32 }} />
</button>
<Typography level='body-xs' sx={{ opacity: 0.7 }}>
{micCaption}
</Typography>
<Typography
level='body-xs'
sx={{ opacity: 0.5, px: 2, textAlign: 'center' }}
>
Pause between tasks &middot; say &ldquo;scratch that&rdquo; to
remove the last one
</Typography>
</Box>
)}
{/* ── Footer — dismissing is the modal's Cancel; this owns confirm only ── */}
{(creating || showActions) && (
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
justifyContent: 'flex-end',
gap: 1,
borderTop: '1px solid',
borderColor: 'divider',
}}
>
{creating ? (
<Button size='sm' variant='solid' color='primary' loading>
Creating
</Button>
) : segments.length === 1 ? (
<Button
size='sm'
variant='solid'
color='primary'
onClick={() =>
onUseSingle(segments[0].text, segments[0].overrides || {})
}
>
Use Task
</Button>
) : (
<Button
size='sm'
variant='solid'
color='primary'
onClick={handleCreateAll}
>
Create {segments.length} Tasks
</Button>
)}
</Box>
)}
</Box>
)
}
export default VoicePanel

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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 }
}