23 Commits

Author SHA1 Message Date
Mo Tarbin
b4d46caee4 Release 1.2.34 2026-07-29 02:45:24 -04:00
Mo Tarbin
f92063dfe9 hotfix: OAuth login being aborted by a forced logout on Android
The app logged itself out in the middle of the OAuth code exchange,
tearing down the page before the callback request could finish.

On resume, the deep-link handler and the sync-on-resume listener fire in
the same tick. Sync ran first, hit a 401 (no session exists yet), and
ApiClient treated that as an expired session: it cleared tokens, wiped
the offline DB, and hard-navigated to /login via window.location.href.
That reload aborted the in-flight POST /auth/oauth2/callback, surfacing
as "Authentication request failed TypeError: Failed to fetch".
2026-07-28 20:45:29 -04:00
Mo Tarbin
ce07e467f3 release 1.2.33 2026-07-28 17:40:44 -04:00
Mohamad Tarbin
f6adc99187 Merge pull request #171 from donetick/support-audio-input
Support audio input
2026-07-28 02:31:38 -04:00
Mohamad Tarbin
950645cc96 Merge branch 'develop' into support-audio-input 2026-07-28 02:31:16 -04:00
Mo Tarbin
cb71b6074d Add Previous for the widgets 2026-07-28 02:15:56 -04:00
Mo Tarbin
bb87dc2e14 Add quick capture widget 2026-07-28 02:02:44 -04:00
Mo Tarbin
ce49e4afc6 Move cancel button to the modal 2026-07-28 02:02:24 -04:00
Mo Tarbin
ea89f90edb fix label picker keep open wnhe something chosen 2026-07-28 00:17:40 -04:00
Mo Tarbin
8cba22024a Fix Choretoolbar display active when it should always stay neutral 2026-07-28 00:03:04 -04:00
Mo Tarbin
9044193aa3 Fix Adding labels to match the way we have it in EditChore 2026-07-27 23:55:27 -04:00
Mo Tarbin
b145bb422b reduce the noise by making the color just a small badge 2026-07-27 23:43:39 -04:00
Mo Tarbin
5a4f67d70a Add back Quick access to the filters 2026-07-27 22:45:49 -04:00
Mo Tarbin
09c3dbbebc fix(voice): harden native recognition loop and match labels to existing ones only
Add heartbeat/timeout recovery for silently-dead Android recognizer sessions,
wait for and prefer the late-arriving final transcript over the interim
partial, and guard against duplicate commits when it lands after restart.
Bias native recognition toward circle member/label names via contextualStrings
so unfamiliar names aren't auto-corrected. Restrict spoken "label X" to
existing labels (exact or closest fuzzy match) instead of creating new ones,
removing the now-unused label-creation flow from AddTaskModal/VoicePanel/
parseVoiceTask.
2026-07-26 21:41:56 -04:00
Mo Tarbin
ad1f85ffcc Update Gemfile 2026-07-26 13:26:51 -04:00
Mohamad Tarbin
ec8eb39599 Merge pull request #165 from donetick/hotfix-07-24
Attempt to fix capacitor sqlite error and update social login to make…
2026-07-25 17:41:05 -04:00
Mo Tarbin
adca4c3af9 Attempt to fix capacitor sqlite error and update social login to make sure we exclude any facebook sdk 2026-07-25 17:35:43 -04:00
Mo Tarbin
a133b271d0 ability to parse user and label 2026-07-20 09:54:39 -04:00
Mo Tarbin
e0257445ef Add Initial Voice Input 2026-07-20 01:41:42 -04:00
Mohamad Tarbin
65c774d0d0 Merge pull request #159 from donetick/respecting-ca-from-user-store
fix(android): trust user-installed CA certificates
2026-07-18 18:00:01 -04:00
Mo Tarbin
2e5f0a3725 fix(android): trust user-installed CA certificates 2026-07-18 17:58:41 -04:00
Mohamad Tarbin
ebae9b78b1 Merge pull request #158 from donetick/add-widget-ios-android
Add home-screen widgets for iOS and Android (Today, Next 7 Days, People)
2026-07-18 17:14:41 -04:00
Mo Tarbin
b73586f81b Add support for iOS and android widgets 2026-07-18 17:10:23 -04:00
88 changed files with 7163 additions and 331 deletions

6
.gitignore vendored
View File

@@ -43,4 +43,8 @@ fastlane/report.xml
fastlane/Preview.html
fastlane/.env
fastlane/*.log
vendor/bundle
vendor/bundle
# Claude Code skills cache
.agents/
skills-lock.json

View File

@@ -1,7 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.9)
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
@@ -230,4 +230,4 @@ DEPENDENCIES
fastlane
BUNDLED WITH
1.17.2
2.4.10

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.donetick.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 37
versionName "1.2.16"
versionCode 55
versionName "1.2.34"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

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

@@ -8,6 +8,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
@@ -37,6 +38,64 @@
</intent-filter>
</activity>
<!-- Home-screen widgets -->
<receiver
android:name=".widget.TodayWidgetProvider"
android:exported="false"
android:label="@string/widget_today_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_today_info" />
</receiver>
<receiver
android:name=".widget.WeekWidgetProvider"
android:exported="false"
android:label="@string/widget_week_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_week_info" />
</receiver>
<receiver
android:name=".widget.PeopleWidgetProvider"
android:exported="false"
android:label="@string/widget_people_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
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"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<activity
android:name=".widget.WidgetConfigActivity"
android:exported="true"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -66,7 +125,22 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Remove advertising ID / Privacy Sandbox ad permissions if transitive SDKs inject them. -->
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_AD_ID" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_CUSTOM_AUDIENCE" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_TOPICS" tools:node="remove" />
<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

@@ -9,11 +9,20 @@ import com.getcapacitor.PluginHandle;
import com.getcapacitor.Plugin;
import android.content.Intent;
import android.nfc.NfcAdapter;
import android.os.Bundle;
import android.util.Log;
import com.donetick.app.widget.WidgetBridgePlugin;
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(WidgetBridgePlugin.class);
super.onCreate(savedInstanceState);
}
// Capacitor only forwards ACTION_VIEW deep links, so normalize Donetick NFC intents before dispatch.
@Override
protected void onNewIntent(Intent intent) {

View File

@@ -0,0 +1,131 @@
package com.donetick.app.widget;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Locale;
/**
* Member avatars for the widgets: downloads and circle-crops profile photos
* (cached on disk for a day) and falls back to a colored initials disc.
* Downloads must only happen from a background/binder thread —
* RemoteViewsFactory.onDataSetChanged qualifies.
*/
final class AvatarCache {
private static final String TAG = "DonetickWidget";
private static final String DIR = "widget_avatars";
private static final long MAX_AGE_MS = 24 * 60 * 60 * 1000;
private static final int SIZE_PX = 96;
// Joy-ish palette for initials discs; picked by hashing the member id so
// each person keeps a stable color.
private static final int[] PALETTE = {
0xFF0B6BCB, 0xFF147D57, 0xFF9C4DD3, 0xFFC2410C,
0xFF0E7490, 0xFFB02A5B, 0xFF5B21B6, 0xFF937800,
};
private AvatarCache() {}
/** Photo avatar or null; never throws, never touches the network on failure loops. */
static Bitmap photo(Context context, WidgetStore.Member member) {
if (member == null || member.image == null || !member.image.startsWith("http")) {
return null;
}
try {
File dir = new File(context.getCacheDir(), DIR);
if (!dir.exists()) dir.mkdirs();
File file = new File(dir, member.id + ".png");
if (file.exists()
&& System.currentTimeMillis() - file.lastModified() < MAX_AGE_MS) {
return BitmapFactory.decodeFile(file.getAbsolutePath());
}
Bitmap downloaded = download(member.image);
if (downloaded == null) {
// Keep serving a stale copy rather than nothing.
return file.exists() ? BitmapFactory.decodeFile(file.getAbsolutePath()) : null;
}
Bitmap circled = circleCrop(downloaded);
try (FileOutputStream out = new FileOutputStream(file)) {
circled.compress(Bitmap.CompressFormat.PNG, 100, out);
}
return circled;
} catch (Exception e) {
Log.w(TAG, "Avatar load failed for member " + member.id, e);
return null;
}
}
/** Colored disc with the member's first initial — the no-photo fallback. */
static Bitmap initials(WidgetStore.Member member) {
String name = member != null && member.name != null ? member.name.trim() : "";
String letter = name.isEmpty()
? "?"
: new String(Character.toChars(name.codePointAt(0))).toUpperCase(Locale.getDefault());
int color = PALETTE[Math.abs((member != null ? member.id : "?").hashCode()) % PALETTE.length];
Bitmap bitmap = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(color);
canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f, paint);
Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
text.setColor(Color.WHITE);
text.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
text.setTextSize(SIZE_PX * 0.42f);
text.setTextAlign(Paint.Align.CENTER);
Rect bounds = new Rect();
text.getTextBounds(letter, 0, letter.length(), bounds);
canvas.drawText(letter, SIZE_PX / 2f, SIZE_PX / 2f + bounds.height() / 2f, text);
return bitmap;
}
/** Best avatar for a member: photo when available, initials otherwise. */
static Bitmap get(Context context, WidgetStore.Member member) {
Bitmap photo = photo(context, member);
return photo != null ? photo : initials(member);
}
private static Bitmap download(String imageUrl) {
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) new URL(imageUrl).openConnection();
connection.setConnectTimeout(8000);
connection.setReadTimeout(10000);
if (connection.getResponseCode() != 200) return null;
try (InputStream stream = connection.getInputStream()) {
Bitmap raw = BitmapFactory.decodeStream(stream);
if (raw == null) return null;
return Bitmap.createScaledBitmap(raw, SIZE_PX, SIZE_PX, true);
}
} catch (Exception e) {
Log.w(TAG, "Avatar download failed", e);
return null;
} finally {
if (connection != null) connection.disconnect();
}
}
private static Bitmap circleCrop(Bitmap source) {
Bitmap output = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setShader(new BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f, paint);
return output;
}
}

View File

@@ -0,0 +1,20 @@
package com.donetick.app.widget;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import com.donetick.app.R;
/** "People" home-screen widget: every circle member with their task load. */
public class PeopleWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
for (int id : appWidgetIds) {
manager.updateAppWidget(id, WidgetUi.build(context, WidgetUi.MODE_PEOPLE, id));
}
// Reload rows; the factory refreshes from the server when stale, which
// is what keeps the widget current while the app stays closed.
manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list);
}
}

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

View File

@@ -0,0 +1,27 @@
package com.donetick.app.widget;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import com.donetick.app.R;
/** "Today" home-screen widget: tasks due today plus anything awaiting approval. */
public class TodayWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
for (int id : appWidgetIds) {
manager.updateAppWidget(id, WidgetUi.build(context, WidgetUi.MODE_TODAY, id));
}
// Reload rows; the factory refreshes from the server when stale, which
// is what keeps the widget current while the app stays closed.
manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list);
}
@Override
public void onDeleted(Context context, int[] appWidgetIds) {
for (int id : appWidgetIds) {
WidgetStore.removeWidgetOptions(context, id);
}
}
}

View File

@@ -0,0 +1,25 @@
package com.donetick.app.widget;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import com.donetick.app.R;
/** "Next 7 Days" home-screen widget: upcoming tasks grouped by day. */
public class WeekWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
for (int id : appWidgetIds) {
manager.updateAppWidget(id, WidgetUi.build(context, WidgetUi.MODE_WEEK, id));
}
manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list);
}
@Override
public void onDeleted(Context context, int[] appWidgetIds) {
for (int id : appWidgetIds) {
WidgetStore.removeWidgetOptions(context, id);
}
}
}

View File

@@ -0,0 +1,31 @@
package com.donetick.app.widget;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/**
* JS bridge for the home-screen widgets (see src/service/WidgetService.js).
* Persists the task snapshot + API config and redraws the widgets.
*/
@CapacitorPlugin(name = "WidgetBridge")
public class WidgetBridgePlugin extends Plugin {
@PluginMethod
public void update(PluginCall call) {
String data = call.getString("data");
String config = call.getString("config");
if (data != null) WidgetStore.saveData(getContext(), data);
if (config != null) WidgetStore.saveConfig(getContext(), config);
WidgetUi.refreshAll(getContext());
call.resolve();
}
@PluginMethod
public void clear(PluginCall call) {
WidgetStore.clear(getContext());
WidgetUi.refreshAll(getContext());
call.resolve();
}
}

View File

@@ -0,0 +1,48 @@
package com.donetick.app.widget;
import android.app.Activity;
import android.appwidget.AppWidgetManager;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Switch;
import com.donetick.app.R;
/**
* Placement / long-press configuration for the Today and Next 7 Days widgets.
* One option for now: include tasks assigned to other circle members
* (stored per appWidgetId so mixed setups work side by side).
*/
public class WidgetConfigActivity extends Activity {
private int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
appWidgetId = getIntent().getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
// Cancelled result until Save, so backing out never adds a half-configured widget.
setResult(RESULT_CANCELED, resultIntent());
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish();
return;
}
setContentView(R.layout.widget_config);
Switch includeOthers = findViewById(R.id.config_include_others);
includeOthers.setChecked(WidgetStore.includeOthers(this, appWidgetId));
findViewById(R.id.config_save).setOnClickListener(v -> {
WidgetStore.setIncludeOthers(this, appWidgetId, includeOthers.isChecked());
WidgetUi.refreshAll(this);
setResult(RESULT_OK, resultIntent());
finish();
});
}
private Intent resultIntent() {
return new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
}
}

View File

@@ -0,0 +1,291 @@
package com.donetick.app.widget;
import android.appwidget.AppWidgetManager;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.view.View;
import android.widget.RemoteViews;
import android.widget.RemoteViewsService;
import androidx.core.content.ContextCompat;
import com.donetick.app.R;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** Feeds task rows, day-group headers, and people rows to the widget ListView. */
public class WidgetListService extends RemoteViewsService {
@Override
public RemoteViewsFactory onGetViewFactory(Intent intent) {
String mode = intent.getStringExtra(WidgetUi.EXTRA_MODE);
int appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
return new WidgetListFactory(getApplicationContext(),
mode != null ? mode : WidgetUi.MODE_TODAY, appWidgetId);
}
static class Row {
final String header; // non-null for day-group headers
final WidgetStore.Task task; // non-null for task rows
final WidgetStore.Member person; // non-null for people rows
int todayCount;
int weekCount;
Row(String header, WidgetStore.Task task, WidgetStore.Member person) {
this.header = header;
this.task = task;
this.person = person;
}
}
static class WidgetListFactory implements RemoteViewsFactory {
private final Context context;
private final String mode;
private final int appWidgetId;
private List<Row> rows = new ArrayList<>();
private final Map<String, Bitmap> avatars = new HashMap<>();
private String myUserId;
private boolean includeOthers;
WidgetListFactory(Context context, String mode, int appWidgetId) {
this.context = context;
this.mode = mode;
this.appWidgetId = appWidgetId;
}
@Override
public void onCreate() {}
@Override
public void onDataSetChanged() {
// Runs on a binder thread, so synchronous network work is allowed
// here. This is the background-refresh path: the 30-minute
// updatePeriodMillis cycle lands here via notifyAppWidgetViewDataChanged.
boolean refreshed = WidgetStore.refreshFromServerIfStale(context);
myUserId = WidgetStore.userId(context);
includeOthers = WidgetStore.includeOthers(context, appWidgetId);
List<WidgetStore.Task> allTasks = WidgetStore.loadTasks(context);
List<WidgetStore.Member> members = WidgetStore.loadMembers(context);
if (WidgetUi.MODE_PEOPLE.equals(mode)) {
rows = buildPeopleRows(allTasks, members);
loadAvatars(members);
} else {
List<WidgetStore.Task> tasks =
WidgetStore.visibleTasks(context, allTasks, includeOthers);
rows = WidgetUi.MODE_TODAY.equals(mode)
? buildTodayRows(tasks)
: buildWeekRows(tasks);
if (includeOthers) loadAvatars(members);
}
if (refreshed) {
// Counts and "Updated …" line live outside the list.
WidgetUi.updateHeaders(context);
}
}
/** Resolve avatars for everyone up front — getViewAt must not block. */
private void loadAvatars(List<WidgetStore.Member> members) {
avatars.clear();
for (WidgetStore.Member member : members) {
avatars.put(member.id, AvatarCache.get(context, member));
}
}
private List<Row> buildTodayRows(List<WidgetStore.Task> tasks) {
List<Row> result = new ArrayList<>();
for (WidgetStore.Task task : WidgetStore.todaySubset(tasks)) {
result.add(new Row(null, task, null));
}
return result;
}
private List<Row> buildWeekRows(List<WidgetStore.Task> tasks) {
List<Row> result = new ArrayList<>();
long startOfToday = WidgetStore.endOfDay(-1) + 1;
SimpleDateFormat dayFormat = new SimpleDateFormat("EEE, MMM d", Locale.getDefault());
List<WidgetStore.Task> approvals = new ArrayList<>();
List<WidgetStore.Task> scheduled = new ArrayList<>();
for (WidgetStore.Task task : tasks) {
if (task.approval) approvals.add(task);
else if (task.dueDate != null) scheduled.add(task);
}
if (!approvals.isEmpty()) {
result.add(new Row(context.getString(R.string.widget_group_approval), null, null));
for (WidgetStore.Task task : approvals) result.add(new Row(null, task, null));
}
String currentGroup = null;
for (WidgetStore.Task task : scheduled) {
String group;
if (task.dueDate < startOfToday) {
group = context.getString(R.string.widget_group_overdue);
} else if (task.dueDate <= WidgetStore.endOfDay(0)) {
group = context.getString(R.string.widget_group_today);
} else if (task.dueDate <= WidgetStore.endOfDay(1)) {
group = context.getString(R.string.widget_group_tomorrow);
} else {
group = dayFormat.format(new Date(task.dueDate));
}
if (!group.equals(currentGroup)) {
result.add(new Row(group, null, null));
currentGroup = group;
}
result.add(new Row(null, task, null));
}
return result;
}
/** One row per member with today/week workloads, busiest first. */
private List<Row> buildPeopleRows(List<WidgetStore.Task> tasks,
List<WidgetStore.Member> members) {
List<Row> result = new ArrayList<>();
List<WidgetStore.Task> todayTasks = WidgetStore.todaySubset(tasks);
for (WidgetStore.Member member : members) {
Row row = new Row(null, null, member);
for (WidgetStore.Task task : tasks) {
if (member.id.equals(task.assignedTo)) row.weekCount++;
}
for (WidgetStore.Task task : todayTasks) {
if (member.id.equals(task.assignedTo)) row.todayCount++;
}
result.add(row);
}
java.util.Collections.sort(result, (a, b) -> {
if (a.todayCount != b.todayCount) return b.todayCount - a.todayCount;
if (a.weekCount != b.weekCount) return b.weekCount - a.weekCount;
return a.person.name.compareToIgnoreCase(b.person.name);
});
return result;
}
@Override
public RemoteViews getViewAt(int position) {
if (position < 0 || position >= rows.size()) return null;
Row row = rows.get(position);
if (row.header != null) {
RemoteViews views = new RemoteViews(context.getPackageName(),
R.layout.widget_row_day_header);
views.setTextViewText(R.id.row_day, row.header);
return views;
}
if (row.person != null) {
return buildPersonRow(row);
}
WidgetStore.Task task = row.task;
RemoteViews views = new RemoteViews(context.getPackageName(),
R.layout.widget_row_task);
views.setTextViewText(R.id.row_title, task.name);
boolean overdue = !task.approval && task.dueDate != null
&& task.dueDate < System.currentTimeMillis();
int secondary = ContextCompat.getColor(context, R.color.widget_text_secondary);
int warning = ContextCompat.getColor(context, R.color.widget_warning);
int danger = ContextCompat.getColor(context, R.color.widget_overdue);
String meta;
int metaColor;
if (task.approval) {
meta = context.getString(R.string.widget_meta_approval);
metaColor = warning;
} else if (overdue) {
meta = context.getString(R.string.widget_meta_overdue);
metaColor = danger;
} else {
meta = android.text.format.DateFormat.getTimeFormat(context)
.format(new Date(task.dueDate));
metaColor = secondary;
}
views.setTextViewText(R.id.row_meta, meta);
views.setTextColor(R.id.row_meta, metaColor);
int ringColor;
if (task.approval) ringColor = warning;
else if (overdue || task.priority == 1) ringColor = danger;
else if (task.priority == 2) ringColor = warning;
else ringColor = ContextCompat.getColor(context, R.color.widget_ring_neutral);
views.setInt(R.id.row_ring, "setColorFilter", ringColor);
// In "everyone" mode, show who a task belongs to (own tasks stay clean).
Bitmap avatar = includeOthers && task.assignedTo != null
&& !task.assignedTo.equals(myUserId)
? avatars.get(task.assignedTo)
: null;
if (avatar != null) {
views.setImageViewBitmap(R.id.row_avatar, avatar);
views.setViewVisibility(R.id.row_avatar, View.VISIBLE);
} else {
views.setViewVisibility(R.id.row_avatar, View.GONE);
}
Intent fillIn = new Intent();
fillIn.setData(Uri.parse("donetick://chores/" + task.id));
views.setOnClickFillInIntent(R.id.row_root, fillIn);
return views;
}
private RemoteViews buildPersonRow(Row row) {
RemoteViews views = new RemoteViews(context.getPackageName(),
R.layout.widget_row_person);
Bitmap avatar = avatars.get(row.person.id);
if (avatar == null) avatar = AvatarCache.initials(row.person);
views.setImageViewBitmap(R.id.person_avatar, avatar);
views.setTextViewText(R.id.person_name, row.person.name);
views.setTextViewText(R.id.person_counts, context.getString(
R.string.widget_person_counts, row.todayCount, row.weekCount));
// Highlight the today-count when someone has work due today.
int accent = ContextCompat.getColor(context, R.color.widget_accent);
int secondary = ContextCompat.getColor(context, R.color.widget_text_secondary);
views.setTextColor(R.id.person_counts, row.todayCount > 0 ? accent : secondary);
Intent fillIn = new Intent();
fillIn.setData(Uri.parse("donetick://chores"));
views.setOnClickFillInIntent(R.id.person_root, fillIn);
return views;
}
@Override
public RemoteViews getLoadingView() {
return null;
}
@Override
public int getViewTypeCount() {
return 3;
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public boolean hasStableIds() {
return false;
}
@Override
public int getCount() {
return rows.size();
}
@Override
public void onDestroy() {}
}
}

View File

@@ -0,0 +1,346 @@
package com.donetick.app.widget;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.List;
/**
* Shared storage + refresh logic for the home-screen widgets.
*
* The web app pushes a pre-filtered snapshot through WidgetBridgePlugin
* whenever its chores cache changes. When the app has not run for a while,
* {@link #refreshFromServerIfStale} re-fetches the list directly so the
* widget stays current in the background (driven by the 30-minute
* updatePeriodMillis cycle).
*
* Snapshot JSON: {version, lastUpdated,
* tasks:[{id, name, dueDate, priority, approval, assignedTo}],
* members:[{id, name, image}]}
* Config JSON: {serverUrl, token, userId}
*
* Since v2 the snapshot holds every member's tasks; widgets narrow it down to
* the current user unless the per-widget "include others" option is on.
*/
public final class WidgetStore {
private static final String TAG = "DonetickWidget";
private static final String PREFS = "donetick_widget";
private static final String KEY_DATA = "widget_tasks";
private static final String KEY_CONFIG = "widget_config";
private static final String KEY_INCLUDE_OTHERS_PREFIX = "include_others_";
// Same filtering window as src/service/WidgetService.js
private static final int WINDOW_DAYS = 7;
private static final int MAX_TASKS = 100;
// Don't hit the network if the app (or a previous refresh) updated the
// snapshot recently; also guards against notify->refresh loops.
private static final long STALE_MS = 10 * 60 * 1000;
private static final Object REFRESH_LOCK = new Object();
private WidgetStore() {}
public static class Task {
public String id;
public String name;
public Long dueDate; // epoch millis, null when unscheduled
public int priority;
public boolean approval;
public String assignedTo; // member userId, null when unassigned
}
public static class Member {
public String id;
public String name;
public String image; // avatar URL, may be null
}
private static SharedPreferences prefs(Context context) {
return context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
public static void saveData(Context context, String json) {
prefs(context).edit().putString(KEY_DATA, json).apply();
}
public static void saveConfig(Context context, String json) {
prefs(context).edit().putString(KEY_CONFIG, json).apply();
}
public static void clear(Context context) {
prefs(context).edit().clear().apply();
}
public static boolean hasConfig(Context context) {
return prefs(context).getString(KEY_CONFIG, null) != null;
}
/** The signed-in user's id from the pushed config, or null. */
public static String userId(Context context) {
try {
String raw = prefs(context).getString(KEY_CONFIG, null);
if (raw == null) return null;
Object id = new JSONObject(raw).opt("userId");
return id == null ? null : String.valueOf(id);
} catch (Exception e) {
return null;
}
}
/** Per-widget "include tasks assigned to others" option (default off). */
public static boolean includeOthers(Context context, int appWidgetId) {
return prefs(context).getBoolean(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId, false);
}
public static void setIncludeOthers(Context context, int appWidgetId, boolean value) {
prefs(context).edit()
.putBoolean(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId, value)
.apply();
}
public static void removeWidgetOptions(Context context, int appWidgetId) {
prefs(context).edit()
.remove(KEY_INCLUDE_OTHERS_PREFIX + appWidgetId)
.apply();
}
public static long lastUpdated(Context context) {
try {
String raw = prefs(context).getString(KEY_DATA, null);
if (raw == null) return 0;
return new JSONObject(raw).optLong("lastUpdated", 0);
} catch (Exception e) {
return 0;
}
}
public static List<Task> loadTasks(Context context) {
List<Task> tasks = new ArrayList<>();
try {
String raw = prefs(context).getString(KEY_DATA, null);
if (raw == null) return tasks;
JSONArray arr = new JSONObject(raw).optJSONArray("tasks");
if (arr == null) return tasks;
for (int i = 0; i < arr.length(); i++) {
JSONObject obj = arr.optJSONObject(i);
if (obj == null) continue;
Task task = new Task();
task.id = String.valueOf(obj.opt("id"));
task.name = obj.optString("name", "");
task.dueDate = obj.isNull("dueDate") ? null : obj.optLong("dueDate");
task.priority = obj.optInt("priority", 0);
task.approval = obj.optBoolean("approval", false);
// v1 snapshots carried only the user's own tasks and had no
// assignedTo — treat those rows as "mine".
task.assignedTo = obj.has("assignedTo")
? (obj.isNull("assignedTo") ? null : String.valueOf(obj.opt("assignedTo")))
: userId(context);
tasks.add(task);
}
} catch (Exception e) {
Log.e(TAG, "Failed to parse widget snapshot", e);
}
return tasks;
}
public static List<Member> loadMembers(Context context) {
List<Member> members = new ArrayList<>();
try {
String raw = prefs(context).getString(KEY_DATA, null);
if (raw == null) return members;
JSONArray arr = new JSONObject(raw).optJSONArray("members");
if (arr == null) return members;
for (int i = 0; i < arr.length(); i++) {
JSONObject obj = arr.optJSONObject(i);
if (obj == null) continue;
Member member = new Member();
member.id = String.valueOf(obj.opt("id"));
member.name = obj.optString("name", "");
member.image = obj.isNull("image") ? null : obj.optString("image", null);
members.add(member);
}
} catch (Exception e) {
Log.e(TAG, "Failed to parse widget members", e);
}
return members;
}
/**
* Tasks a today/week widget should render: everything when includeOthers,
* otherwise the user's own tasks plus approvals (which wait on them).
*/
public static List<Task> visibleTasks(Context context, List<Task> tasks, boolean includeOthers) {
if (includeOthers) return tasks;
String me = userId(context);
List<Task> mine = new ArrayList<>();
for (Task task : tasks) {
if (task.approval || (me != null && me.equals(task.assignedTo))) {
mine.add(task);
}
}
return mine;
}
/** Tasks the Today widget shows: awaiting approval, overdue, or due today. */
public static List<Task> todaySubset(List<Task> tasks) {
long endOfToday = endOfDay(0);
List<Task> subset = new ArrayList<>();
for (Task task : tasks) {
if (task.approval || (task.dueDate != null && task.dueDate <= endOfToday)) {
subset.add(task);
}
}
return subset;
}
public static long endOfDay(int daysFromNow) {
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_YEAR, daysFromNow);
cal.set(Calendar.HOUR_OF_DAY, 23);
cal.set(Calendar.MINUTE, 59);
cal.set(Calendar.SECOND, 59);
cal.set(Calendar.MILLISECOND, 999);
return cal.getTimeInMillis();
}
/**
* Fetch /chores/ from the configured server and rebuild the snapshot.
* Returns true when a network refresh actually happened and succeeded.
* Safe to call from RemoteViewsFactory.onDataSetChanged (binder thread).
*/
public static boolean refreshFromServerIfStale(Context context) {
synchronized (REFRESH_LOCK) {
long age = System.currentTimeMillis() - lastUpdated(context);
if (age < STALE_MS) return false;
String rawConfig = prefs(context).getString(KEY_CONFIG, null);
if (rawConfig == null) return false;
HttpURLConnection connection = null;
try {
JSONObject config = new JSONObject(rawConfig);
String serverUrl = config.optString("serverUrl", "");
String token = config.optString("token", "");
if (serverUrl.isEmpty() || token.isEmpty()) return false;
URL url = new URL(serverUrl + "/chores/");
connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(10000);
connection.setReadTimeout(15000);
connection.setRequestProperty("Authorization", "Bearer " + token);
connection.setRequestProperty("Accept", "application/json");
if (connection.getResponseCode() != 200) {
// Expired token or server trouble — keep the last snapshot,
// the UI surfaces staleness via the "Updated …" line.
Log.w(TAG, "Widget refresh got HTTP " + connection.getResponseCode());
return false;
}
JSONArray chores = new JSONObject(readAll(connection.getInputStream()))
.optJSONArray("res");
if (chores == null) return false;
// The chores endpoint has no member profiles, so carry the
// member list over from the previous snapshot (it changes
// rarely and the app re-pushes it on every open).
JSONArray members = null;
String previous = prefs(context).getString(KEY_DATA, null);
if (previous != null) {
members = new JSONObject(previous).optJSONArray("members");
}
JSONObject snapshot = new JSONObject();
snapshot.put("version", 2);
snapshot.put("lastUpdated", System.currentTimeMillis());
snapshot.put("tasks", filterChores(chores));
snapshot.put("members", members == null ? new JSONArray() : members);
saveData(context, snapshot.toString());
return true;
} catch (Exception e) {
Log.w(TAG, "Widget background refresh failed", e);
return false;
} finally {
if (connection != null) connection.disconnect();
}
}
}
/** Mirror of buildWidgetTasks in src/service/WidgetService.js. */
private static JSONArray filterChores(JSONArray chores) throws Exception {
long cutoff = endOfDay(WINDOW_DAYS);
List<JSONObject> selected = new ArrayList<>();
for (int i = 0; i < chores.length(); i++) {
JSONObject chore = chores.optJSONObject(i);
if (chore == null || chore.opt("id") == null) continue;
boolean approval = chore.optInt("status", 0) == 3;
Long dueDate = parseDate(chore.optString("nextDueDate", null));
boolean inWindow = dueDate != null && dueDate <= cutoff;
if (!approval && !inWindow) continue;
JSONObject task = new JSONObject();
task.put("id", chore.opt("id"));
task.put("name", chore.optString("name", ""));
task.put("dueDate", dueDate == null ? JSONObject.NULL : dueDate);
task.put("priority", chore.optInt("priority", 0));
task.put("approval", approval);
task.put("assignedTo", chore.isNull("assignedTo")
? JSONObject.NULL
: String.valueOf(chore.opt("assignedTo")));
selected.add(task);
}
Collections.sort(selected, (a, b) -> {
boolean aApproval = a.optBoolean("approval");
boolean bApproval = b.optBoolean("approval");
if (aApproval != bApproval) return aApproval ? -1 : 1;
long aDue = a.isNull("dueDate") ? Long.MAX_VALUE : a.optLong("dueDate");
long bDue = b.isNull("dueDate") ? Long.MAX_VALUE : b.optLong("dueDate");
return Long.compare(aDue, bDue);
});
JSONArray result = new JSONArray();
for (int i = 0; i < selected.size() && i < MAX_TASKS; i++) {
result.put(selected.get(i));
}
return result;
}
private static Long parseDate(String value) {
if (value == null || value.isEmpty() || "null".equals(value)) return null;
try {
return OffsetDateTime.parse(value).toInstant().toEpochMilli();
} catch (Exception e) {
return null;
}
}
private static String readAll(InputStream stream) throws Exception {
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
builder.append(line);
}
}
return builder.toString();
}
}

View File

@@ -0,0 +1,141 @@
package com.donetick.app.widget;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.view.View;
import android.widget.RemoteViews;
import com.donetick.app.MainActivity;
import com.donetick.app.R;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/** Builds the shell RemoteViews shared by the widgets and pushes updates. */
public final class WidgetUi {
public static final String MODE_TODAY = "today";
public static final String MODE_WEEK = "week";
public static final String MODE_PEOPLE = "people";
public static final String EXTRA_MODE = "com.donetick.app.widget.MODE";
private WidgetUi() {}
/** Full refresh: redraw headers and reload list content (used by the JS bridge). */
public static void refreshAll(Context context) {
updateHeaders(context);
AppWidgetManager manager = AppWidgetManager.getInstance(context);
manager.notifyAppWidgetViewDataChanged(
widgetIds(context, manager, TodayWidgetProvider.class), R.id.widget_list);
manager.notifyAppWidgetViewDataChanged(
widgetIds(context, manager, WeekWidgetProvider.class), R.id.widget_list);
manager.notifyAppWidgetViewDataChanged(
widgetIds(context, manager, PeopleWidgetProvider.class), R.id.widget_list);
}
/** Redraw title/count/subtitle only — safe to call from the list factory. */
public static void updateHeaders(Context context) {
AppWidgetManager manager = AppWidgetManager.getInstance(context);
for (int id : widgetIds(context, manager, TodayWidgetProvider.class)) {
manager.updateAppWidget(id, build(context, MODE_TODAY, id));
}
for (int id : widgetIds(context, manager, WeekWidgetProvider.class)) {
manager.updateAppWidget(id, build(context, MODE_WEEK, id));
}
for (int id : widgetIds(context, manager, PeopleWidgetProvider.class)) {
manager.updateAppWidget(id, build(context, MODE_PEOPLE, id));
}
}
private static int[] widgetIds(Context context, AppWidgetManager manager, Class<?> provider) {
return manager.getAppWidgetIds(new ComponentName(context, provider));
}
public static RemoteViews build(Context context, String mode, int appWidgetId) {
boolean today = MODE_TODAY.equals(mode);
boolean people = MODE_PEOPLE.equals(mode);
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_shell);
int titleRes = today ? R.string.widget_today_title
: people ? R.string.widget_people_title
: R.string.widget_week_title;
views.setTextViewText(R.id.widget_title, context.getString(titleRes));
if (people) {
views.setViewVisibility(R.id.widget_count, View.GONE);
} else {
boolean includeOthers = WidgetStore.includeOthers(context, appWidgetId);
List<WidgetStore.Task> tasks = WidgetStore.visibleTasks(
context, WidgetStore.loadTasks(context), includeOthers);
int count = (today ? WidgetStore.todaySubset(tasks) : tasks).size();
views.setTextViewText(R.id.widget_count, String.valueOf(count));
views.setViewVisibility(R.id.widget_count, count > 0 ? View.VISIBLE : View.GONE);
}
views.setTextViewText(R.id.widget_subtitle, subtitle(context));
if (!WidgetStore.hasConfig(context)) {
views.setTextViewText(R.id.widget_empty,
context.getString(R.string.widget_signed_out));
} else {
int emptyRes = today ? R.string.widget_empty_today
: people ? R.string.widget_empty_people
: R.string.widget_empty_week;
views.setTextViewText(R.id.widget_empty, context.getString(emptyRes));
}
// The Today widget gets a quick-add button that deep links straight
// into the in-app AddTaskModal.
if (today) {
Intent addTask = new Intent(context, MainActivity.class);
addTask.setAction(Intent.ACTION_VIEW);
addTask.setData(Uri.parse("donetick://chores/add"));
views.setOnClickPendingIntent(R.id.widget_add, PendingIntent.getActivity(
context, 2, addTask,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
views.setViewVisibility(R.id.widget_add, View.VISIBLE);
} else {
views.setViewVisibility(R.id.widget_add, View.GONE);
}
// List content comes from WidgetListService; mode travels in the intent
// and the unique data URI keeps the adapters from being collapsed.
Intent adapterIntent = new Intent(context, WidgetListService.class);
adapterIntent.putExtra(EXTRA_MODE, mode);
adapterIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
adapterIntent.setData(Uri.parse("donetickwidget://" + mode + "/" + appWidgetId));
views.setRemoteAdapter(R.id.widget_list, adapterIntent);
views.setEmptyView(R.id.widget_list, R.id.widget_empty);
// Row taps deep link into the chore view; the row's fill-in intent
// supplies the donetick://chores/<id> data URI.
Intent rowTemplate = new Intent(context, MainActivity.class);
rowTemplate.setAction(Intent.ACTION_VIEW);
views.setPendingIntentTemplate(R.id.widget_list, PendingIntent.getActivity(
context, 1, rowTemplate,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE));
// Anywhere else on the widget simply opens the app.
Intent openApp = new Intent(context, MainActivity.class);
views.setOnClickPendingIntent(R.id.widget_container, PendingIntent.getActivity(
context, 0, openApp,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
return views;
}
private static String subtitle(Context context) {
String date = new SimpleDateFormat("EEE, MMM d", Locale.getDefault())
.format(new Date());
long lastUpdated = WidgetStore.lastUpdated(context);
if (lastUpdated <= 0) return date;
String time = android.text.format.DateFormat.getTimeFormat(context)
.format(new Date(lastUpdated));
return date + " · " + context.getString(R.string.widget_updated_at, time);
}
}

View File

@@ -0,0 +1 @@
{"version":1,"sessions":{}}

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,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widget_bg" />
<!-- Match the launcher's system-wide widget corner radius on Android 12+ -->
<corners android:radius="@android:dimen/system_app_widget_background_radius" />
</shape>

View File

@@ -0,0 +1,11 @@
<?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="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
</vector>

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,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widget_bg" />
<corners android:radius="24dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widget_accent_soft" />
<corners android:radius="10dp" />
</shape>

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"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@android:color/transparent" />
<stroke android:width="2dp" android:color="#FFFFFF" />
<size android:width="18dp" android:height="18dp" />
</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,43 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/widget_bg"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_config_title"
android:textColor="@color/widget_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<Switch
android:id="@+id/config_include_others"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:minHeight="48dp"
android:text="@string/widget_config_include_others"
android:textColor="@color/widget_text_primary"
android:textSize="16sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/widget_config_include_others_hint"
android:textColor="@color/widget_text_secondary"
android:textSize="13sp" />
<Button
android:id="@+id/config_save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:backgroundTint="@color/widget_accent"
android:text="@string/widget_config_save"
android:textColor="#FFFFFF" />
</LinearLayout>

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

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/row_day"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:letterSpacing="0.08"
android:paddingTop="8dp"
android:paddingBottom="2dp"
android:textAllCaps="true"
android:textColor="@color/widget_text_secondary"
android:textSize="10sp"
android:textStyle="bold" />

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/person_root"
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">
<ImageView
android:id="@+id/person_avatar"
android:layout_width="28dp"
android:layout_height="28dp"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/person_name"
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:textColor="@color/widget_text_primary"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/person_counts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
</LinearLayout>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/row_root"
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:id="@+id/row_ring"
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/widget_ring" />
<TextView
android:id="@+id/row_title"
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:textColor="@color/widget_text_primary"
android:textSize="14sp" />
<TextView
android:id="@+id/row_meta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/widget_text_secondary"
android:textSize="12sp" />
<!-- Assignee avatar, shown only in "everyone" mode for others' tasks. -->
<ImageView
android:id="@+id/row_avatar"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:importantForAccessibility="no"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
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:id="@+id/widget_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/widget_count"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widget_text_primary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toStartOf="@+id/widget_add"
android:layout_centerVertical="true"
android:background="@drawable/widget_count_bg"
android:minWidth="22dp"
android:gravity="center"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:textColor="@color/widget_accent"
android:textSize="12sp"
android:textStyle="bold" />
<!-- Quick-add, shown on the Today widget only (deep links to the
in-app AddTaskModal). Gone by default so the other widgets keep
the count pill flush with the edge. -->
<ImageView
android:id="@+id/widget_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"
android:visibility="gone" />
</RelativeLayout>
<TextView
android:id="@+id/widget_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widget_text_secondary"
android:textSize="11sp" />
<ListView
android:id="@+id/widget_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="6dp"
android:divider="@null"
android:dividerHeight="0dp"
android:listSelector="@android:color/transparent"
android:scrollbars="none" />
<TextView
android:id="@+id/widget_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:lineSpacingExtra="3dp"
android:textColor="@color/widget_text_secondary"
android:textSize="13sp"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widget_bg">#181C20</color>
<color name="widget_text_primary">#F0F4F8</color>
<color name="widget_text_secondary">#9FA6AD</color>
<color name="widget_accent">#4B9BE8</color>
<color name="widget_accent_soft">#12395F</color>
<color name="widget_overdue">#F09898</color>
<color name="widget_warning">#F3C896</color>
<color name="widget_ring_neutral">#555E68</color>
<color name="widget_divider">#252B31</color>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Home-screen widget palette (values-night overrides for dark mode) -->
<color name="widget_bg">#FFFFFF</color>
<color name="widget_text_primary">#171A1C</color>
<color name="widget_text_secondary">#6B7681</color>
<color name="widget_accent">#0B6BCB</color>
<color name="widget_accent_soft">#E3EFFB</color>
<color name="widget_overdue">#C41C1C</color>
<color name="widget_warning">#B26A00</color>
<color name="widget_ring_neutral">#B8C0C9</color>
<color name="widget_divider">#EDF1F5</color>
</resources>

View File

@@ -4,4 +4,58 @@
<string name="title_activity_main">Donetick</string>
<string name="package_name">com.donetick.app</string>
<string name="custom_url_scheme">com.donetick.app</string>
<!-- Home-screen widgets -->
<string name="widget_today_label">Today</string>
<string name="widget_week_label">Next 7 Days</string>
<string name="widget_today_description">Your tasks due today, plus anything waiting on you.</string>
<string name="widget_week_description">Your tasks for the next 7 days, grouped by day.</string>
<string name="widget_today_title">Today</string>
<string name="widget_week_title">Next 7 days</string>
<string name="widget_empty_today">All caught up!\nNothing due today</string>
<string name="widget_empty_week">All caught up!\nNothing due this week</string>
<string name="widget_signed_out">Open Donetick and sign in\nto see your tasks</string>
<string name="widget_meta_overdue">Overdue</string>
<string name="widget_meta_approval">Approve</string>
<string name="widget_group_approval">Needs approval</string>
<string name="widget_group_overdue">Overdue</string>
<string name="widget_group_today">Today</string>
<string name="widget_group_tomorrow">Tomorrow</string>
<string name="widget_updated_at">Updated %s</string>
<string name="widget_add_task">Add task</string>
<string name="widget_people_label">People</string>
<string name="widget_people_description">Everyone in your circle with their tasks for today and the week ahead.</string>
<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

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!--
Trust both the system CA store and user-installed CA certificates.
This lets self-hosted Donetick instances that use custom or self-signed
certificates work once the user installs their CA on the device, without
having to modify/repackage the APK. See donetick/donetick#727.
-->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-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"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

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

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-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"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:configure="com.donetick.app.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" />

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-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"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="3"
android:updatePeriodMillis="1800000"
android:configure="com.donetick.app.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" />

View File

@@ -22,6 +22,12 @@ allprojects {
google()
mavenCentral()
}
configurations.configureEach {
// The app does not use Advertising ID. Exclude RevenueCat's transitive
// ads identifier dependency so Play Console does not require an AD_ID declaration.
exclude group: 'com.google.android.gms', module: 'play-services-ads-identifier'
}
}
task clean(type: Delete) {

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

@@ -16,6 +16,22 @@ const config: CapacitorConfig = {
iconColor: '#488AFF',
sound: 'beep.wav',
},
CapacitorSQLite: {
// The offline cache opens databases with encrypted: false / no-encryption.
// @capacitor-community/sqlite defaults native encryption to true when this
// block is absent; on Android that can make plugin load fail with
// "CapacitorSQLitePlugin: null" before JS gets a chance to open the DB.
iosIsEncryption: false,
androidIsEncryption: false,
},
SocialLogin: {
providers: {
google: true,
facebook: false,
apple: true,
twitter: false,
},
},
// GoogleAuth: {
// scopes: ['profile', 'email', 'openid'],
// clientId: process.env.VITE_APP_GOOGLE_CLIENT_ID,

View File

@@ -26,6 +26,7 @@ platform :ios do
signingStyle: "manual",
provisioningProfiles: {
"com.donetick.app" => "Donetick App Store(fastline)",
"com.donetick.app.widget" => "Donetick Widget App Store(fastline)",
},
},
)

View File

@@ -17,6 +17,10 @@
72FA9293C4A649D1BA0E5917 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 71866EB277374608AD02C137 /* PrivacyInfo.xcprivacy */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
D1115B492C653D60004C6043 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D1115B482C653D60004C6043 /* GoogleService-Info.plist */; };
D0AC000000000000000000B1 /* WidgetBridgePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A1 /* WidgetBridgePlugin.swift */; };
D0AC000000000000000000B2 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A2 /* MainViewController.swift */; };
D0AC000000000000000000B3 /* DonetickWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A3 /* DonetickWidget.swift */; };
D0AC000000000000000000B4 /* DonetickWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -35,8 +39,38 @@
D1115B482C653D60004C6043 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
D1115B4A2C659D51004C6043 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
D0AC000000000000000000A1 /* WidgetBridgePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBridgePlugin.swift; sourceTree = "<group>"; };
D0AC000000000000000000A2 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
D0AC000000000000000000A3 /* DonetickWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonetickWidget.swift; sourceTree = "<group>"; };
D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DonetickWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D0AC000000000000000000A5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D0AC000000000000000000A6 /* DonetickWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DonetickWidget.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXCopyFilesBuildPhase section */
D0AC000000000000000000C2 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
D0AC000000000000000000B4 /* DonetickWidgetExtension.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXContainerItemProxy section */
D0AC000000000000000000E2 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = D0AC000000000000000000D1;
remoteInfo = DonetickWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -46,6 +80,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D0AC000000000000000000D3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -61,6 +102,7 @@
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
D0AC000000000000000000C1 /* DonetickWidget */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
@@ -68,10 +110,21 @@
);
sourceTree = "<group>";
};
D0AC000000000000000000C1 /* DonetickWidget */ = {
isa = PBXGroup;
children = (
D0AC000000000000000000A3 /* DonetickWidget.swift */,
D0AC000000000000000000A5 /* Info.plist */,
D0AC000000000000000000A6 /* DonetickWidget.entitlements */,
);
path = DonetickWidget;
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -82,6 +135,8 @@
D1115B4A2C659D51004C6043 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
D0AC000000000000000000A2 /* MainViewController.swift */,
D0AC000000000000000000A1 /* WidgetBridgePlugin.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
@@ -114,16 +169,35 @@
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
D0AC000000000000000000C2 /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
D0AC000000000000000000E1 /* PBXTargetDependency */,
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
D0AC000000000000000000D1 /* DonetickWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = D0AC000000000000000000F1 /* Build configuration list for PBXNativeTarget "DonetickWidgetExtension" */;
buildPhases = (
D0AC000000000000000000D2 /* Sources */,
D0AC000000000000000000D3 /* Frameworks */,
D0AC000000000000000000D4 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = DonetickWidgetExtension;
productName = DonetickWidgetExtension;
productReference = D0AC000000000000000000A4 /* DonetickWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -138,6 +212,10 @@
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
D0AC000000000000000000D1 = {
CreatedOnToolsVersion = 15.0;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -156,6 +234,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
D0AC000000000000000000D1 /* DonetickWidgetExtension */,
);
};
/* End PBXProject section */
@@ -176,6 +255,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D0AC000000000000000000D4 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -220,11 +306,29 @@
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
D0AC000000000000000000B2 /* MainViewController.swift in Sources */,
D0AC000000000000000000B1 /* WidgetBridgePlugin.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D0AC000000000000000000D2 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D0AC000000000000000000B3 /* DonetickWidget.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
D0AC000000000000000000E1 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D0AC000000000000000000D1 /* DonetickWidgetExtension */;
targetProxy = D0AC000000000000000000E2 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -358,12 +462,12 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37;
CURRENT_PROJECT_VERSION = 55;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.16;
MARKETING_VERSION = 1.2.34;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
@@ -381,12 +485,12 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
CURRENT_PROJECT_VERSION = 37;
CURRENT_PROJECT_VERSION = 55;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.16;
MARKETING_VERSION = 1.2.34;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -395,6 +499,48 @@
};
name = Release;
};
D0AC000000000000000000F2 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 55;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = DonetickWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.2.34;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
D0AC000000000000000000F3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
CURRENT_PROJECT_VERSION = 55;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = DonetickWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.2.34;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -416,6 +562,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D0AC000000000000000000F1 /* Build configuration list for PBXNativeTarget "DonetickWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D0AC000000000000000000F2 /* Debug */,
D0AC000000000000000000F3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;

View File

@@ -4,6 +4,10 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.donetick.app</string>
</array>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>

View File

@@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="MainViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>

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

@@ -0,0 +1,9 @@
import Capacitor
import UIKit
/// Registers app-local Capacitor plugins; referenced from Main.storyboard.
class MainViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
bridge?.registerPluginInstance(WidgetBridgePlugin())
}
}

View File

@@ -0,0 +1,46 @@
import Capacitor
import Foundation
import WidgetKit
/// JS bridge for the home-screen widgets (see src/service/WidgetService.js).
/// Persists the task snapshot + API config in the shared App Group so the
/// widget extension can read them, then asks WidgetKit to redraw.
@objc(WidgetBridgePlugin)
public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "WidgetBridgePlugin"
public let jsName = "WidgetBridge"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise),
]
static let appGroup = "group.com.donetick.app"
static let dataKey = "widget_tasks"
static let configKey = "widget_config"
@objc func update(_ call: CAPPluginCall) {
guard let defaults = UserDefaults(suiteName: Self.appGroup) else {
call.reject("App Group \(Self.appGroup) unavailable")
return
}
if let data = call.getString("data") {
defaults.set(data, forKey: Self.dataKey)
}
if let config = call.getString("config") {
defaults.set(config, forKey: Self.configKey)
}
WidgetCenter.shared.reloadAllTimelines()
call.resolve()
}
@objc func clear(_ call: CAPPluginCall) {
guard let defaults = UserDefaults(suiteName: Self.appGroup) else {
call.reject("App Group \(Self.appGroup) unavailable")
return
}
defaults.removeObject(forKey: Self.dataKey)
defaults.removeObject(forKey: Self.configKey)
WidgetCenter.shared.reloadAllTimelines()
call.resolve()
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.donetick.app</string>
</array>
</dict>
</plist>

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>DoneTick Widget</string>
<string>Donetick</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>

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):
@@ -43,20 +47,10 @@ PODS:
- Capacitor
- CapgoCapacitorNfc (8.2.2):
- Capacitor
- CapgoCapacitorSocialLogin (8.3.36):
- CapgoCapacitorSocialLogin (8.3.38):
- Alamofire (~> 5.10.2)
- Capacitor
- FBSDKCoreKit (~> 18.0)
- FBSDKLoginKit (~> 18.0)
- GoogleSignIn (~> 9.0.0)
- FBAEMKit (18.1.0):
- FBSDKCoreKit_Basics (= 18.1.0)
- FBSDKCoreKit (18.1.0):
- FBAEMKit (= 18.1.0)
- FBSDKCoreKit_Basics (= 18.1.0)
- FBSDKCoreKit_Basics (18.1.0)
- FBSDKLoginKit (18.1.0):
- FBSDKCoreKit (= 18.1.0)
- FirebaseCore (12.15.0):
- FirebaseCoreInternal (~> 12.15.0)
- GoogleUtilities/Environment (~> 8.1)
@@ -149,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`)"
@@ -172,10 +168,6 @@ SPEC REPOS:
- Alamofire
- AppAuth
- AppCheckCore
- FBAEMKit
- FBSDKCoreKit
- FBSDKCoreKit_Basics
- FBSDKLoginKit
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
@@ -203,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:
@@ -243,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
@@ -255,11 +253,7 @@ SPEC CHECKSUMS:
CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32
CapgoCapacitorDocumentScanner: 7ad9e8ed9c054d551bfc948660d995b9545723d8
CapgoCapacitorNfc: 8ea158143c441e1cf6d231a971e375511558d027
CapgoCapacitorSocialLogin: adffda2bec52a765bdaebf3fa9a6083bd9341d60
FBAEMKit: 64088ff0380f50e4a56e2ee07d984f7f1aef7595
FBSDKCoreKit: 8390ea3a8fac186f444275f31d7512e760b47cba
FBSDKCoreKit_Basics: 3a0005c78b355388bf2df5c6dbe6381b77ea4be3
FBSDKLoginKit: d8e2711a3a03b0026703735c8d28a2b5a0e1f4fd
CapgoCapacitorSocialLogin: 5de0c9295188cbd9116d13e4581494f3bbaa3414
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
@@ -283,6 +277,6 @@ SPEC CHECKSUMS:
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 21b805bdbbb6ac4b8a3527ee8ef346ba0a55ef65
PODFILE CHECKSUM: 1099083fe561f8852fcef1bfcff4051f45db9770
COCOAPODS: 1.16.2

187
package-lock.json generated
View File

@@ -1,19 +1,22 @@
{
"name": "donetick",
"version": "1.2.15",
"version": "1.2.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "donetick",
"version": "1.2.15",
"version": "1.2.33",
"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",
@@ -23,7 +26,7 @@
"@capacitor/status-bar": "^8.0.0",
"@capgo/capacitor-document-scanner": "^8.4.0",
"@capgo/capacitor-nfc": "^8.0.0",
"@capgo/capacitor-social-login": "^8.0.0",
"@capgo/capacitor-social-login": "^8.3.38",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -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",
@@ -2135,9 +2157,9 @@
}
},
"node_modules/@capgo/capacitor-social-login": {
"version": "8.3.36",
"resolved": "https://registry.npmjs.org/@capgo/capacitor-social-login/-/capacitor-social-login-8.3.36.tgz",
"integrity": "sha512-Y4HYjEJablzbHHwPBrbC2UvHVPsR0bAyCAqBzVBoUEOf0UFHAHyTP3gD9JUTKP/iERU8I+6IA2RWtLqDePCNuA==",
"version": "8.3.38",
"resolved": "https://registry.npmjs.org/@capgo/capacitor-social-login/-/capacitor-social-login-8.3.38.tgz",
"integrity": "sha512-SHrQ9gVJ2oCxf6dQu6Bq4PwdzYIYX7OVuUNyuGPFxLWPR8DCJM50gEhdAmunzEVd1vlty0ShH+84IOU5FEHLPg==",
"license": "MPL-2.0",
"dependencies": {
"tslib": "^2.8.1"
@@ -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

@@ -1,7 +1,7 @@
{
"name": "donetick",
"private": true,
"version": "1.2.16",
"version": "1.2.34",
"type": "module",
"engines": {
"node": ">=20.0.0",
@@ -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",
@@ -53,7 +56,7 @@
"@capacitor/status-bar": "^8.0.0",
"@capgo/capacitor-document-scanner": "^8.4.0",
"@capgo/capacitor-nfc": "^8.0.0",
"@capgo/capacitor-social-login": "^8.0.0",
"@capgo/capacitor-social-login": "^8.3.38",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -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

@@ -7,6 +7,7 @@ import { Preferences } from '@capacitor/preferences'
import { PushNotifications } from '@capacitor/push-notifications'
import { focusManager } from '@tanstack/react-query'
import { RegisterDeviceToken } from './utils/Fetcher'
import { beginOAuthExchange } from './utils/OAuthExchangeState'
// React Router navigate(), injected by <App /> once the router is mounted.
// Using client-side navigation (instead of window.location.href) avoids a full
@@ -62,7 +63,22 @@ const handleNFCChoreDeepLink = (url, isColdStart) => {
const handleUrlOpen = (url, isColdStart = false) => {
console.log('[NFC] handleUrlOpen:', url)
if (url.startsWith('donetick://chores/')) {
if (url.startsWith('donetick://chores/add')) {
// 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/')) {
handleOAuthDeepLink(url)
@@ -89,6 +105,13 @@ const handleOAuthDeepLink = async url => {
return
}
// Claim the exchange window synchronously, before the first await: the
// resume-driven background sync fires in the same tick as this deep link,
// and its 401 must not be mistaken for an expired session. Set after the
// early return above so the flag is only ever claimed by the navigation
// that Authenticating.jsx will clear.
beginOAuthExchange()
// Store the OAuth params for the app to pick up
await Preferences.set({
key: 'oauth_callback',

View File

@@ -1,16 +1,23 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { initWidgetSync } from '../service/WidgetService'
const QueryContext = ({ children }) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 300000, // 5 minutes
gcTime: 600000, // 10 minutes
refetchOnWindowFocus: false,
retry: 1,
},
},
})
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 300000, // 5 minutes
gcTime: 600000, // 10 minutes
refetchOnWindowFocus: false,
retry: 1,
},
},
}),
)
useEffect(() => initWidgetSync(queryClient), [queryClient])
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>

View File

@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import { commandQueue } from '../utils/CommandQueue'
import { offlineDB } from '../utils/OfflineDB'
import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
import { syncEngine } from '../utils/SyncEngine'
import { networkManager } from './NetworkManager'
@@ -90,6 +91,11 @@ export function useSyncOnReconnect() {
const runSync = async () => {
if (!isOfflineFeatureEnabled()) return
// Skip while the OAuth code exchange is in flight — there's no session
// yet, so a sync here just 401s. Note the app-resume listener fires in
// the same tick as the deep link, before the route changes, so this has
// to test the shared flag rather than the pathname.
if (isOAuthExchangeInProgress()) return
const wasOffline = !networkManager.isOnline
const didSync = await syncEngine.sync()
if (didSync) {

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

@@ -0,0 +1,145 @@
import { Capacitor, registerPlugin } from '@capacitor/core'
import { apiClient } from '../utils/ApiClient'
// Native bridge implemented in ios/App/App/WidgetBridgePlugin.swift and
// android/.../widget/WidgetBridgePlugin.java. It persists the snapshot in
// storage the home-screen widgets can read (App Group defaults on iOS,
// SharedPreferences on Android) and asks the OS to redraw them.
const WidgetBridge = registerPlugin('WidgetBridge')
const WINDOW_DAYS = 7
const MAX_TASKS = 100
const MAX_MEMBERS = 12
const PUSH_DEBOUNCE_MS = 1500
const isNative = () => Capacitor.isNativePlatform()
// The snapshot carries every circle member's actionable tasks (due inside the
// 7-day window, overdue included, or awaiting approval — status 3). Each task
// records its assignee so the widgets can filter down to "mine" (the default)
// or show everyone, per the user's widget configuration; the People widget
// derives per-member counts from the same list. The Today widget re-derives
// its subset natively from dueDate so one snapshot feeds all widgets.
export const buildWidgetTasks = chores => {
const endOfWindow = new Date()
endOfWindow.setHours(23, 59, 59, 999)
endOfWindow.setDate(endOfWindow.getDate() + WINDOW_DAYS)
const cutoff = endOfWindow.getTime()
return (chores || [])
.map(chore => {
if (!chore || chore.id == null) return null
const approval = chore.status === 3
const dueDate = chore.nextDueDate
? new Date(chore.nextDueDate).getTime()
: null
const inWindow = dueDate !== null && dueDate <= cutoff
if (!approval && !inWindow) return null
return {
id: chore.id,
name: chore.name || '',
dueDate,
priority: chore.priority || 0,
approval,
assignedTo: chore.assignedTo == null ? null : String(chore.assignedTo),
}
})
.filter(Boolean)
.sort((a, b) => {
if (a.approval !== b.approval) return a.approval ? -1 : 1
if (a.dueDate === null) return b.dueDate === null ? 0 : 1
if (b.dueDate === null) return -1
return a.dueDate - b.dueDate
})
.slice(0, MAX_TASKS)
}
// Circle members, trimmed to what the widgets render: avatar + short name.
export const buildWidgetMembers = members => {
return (members || [])
.filter(member => member && member.userId != null)
.slice(0, MAX_MEMBERS)
.map(member => ({
id: String(member.userId),
name: member.displayName || member.username || '',
image: member.image || null,
}))
}
const pushSnapshot = async queryClient => {
const choresData = queryClient.getQueryData(['chores', false])
const chores = choresData?.res
if (!Array.isArray(chores)) return
const profileQuery = queryClient
.getQueryCache()
.findAll({ queryKey: ['userProfile'] })
.find(q => q.state.data?.id != null)
const userId = profileQuery?.state.data?.id
if (userId == null) return
const token = apiClient.getToken()
if (!token) return
const members = queryClient.getQueryData(['allCircleMembers'])?.res
await WidgetBridge.update({
data: JSON.stringify({
version: 2,
lastUpdated: Date.now(),
tasks: buildWidgetTasks(chores),
members: buildWidgetMembers(members),
}),
config: JSON.stringify({
serverUrl: apiClient.getApiURL(),
token,
userId,
}),
})
}
/**
* Watch the react-query cache and mirror every chores update into the
* home-screen widgets. Covers both fresh fetches and local mutations
* (complete/skip/approve write through the same cache key).
*/
export const initWidgetSync = queryClient => {
if (!isNative()) return () => {}
let timer = null
const schedule = () => {
clearTimeout(timer)
timer = setTimeout(() => {
pushSnapshot(queryClient).catch(err =>
console.error('Widget sync failed', err),
)
}, PUSH_DEBOUNCE_MS)
}
const unsubscribe = queryClient.getQueryCache().subscribe(event => {
const key = event?.query?.queryKey
if (
event?.type === 'updated' &&
(key?.[0] === 'chores' ||
key?.[0] === 'userProfile' ||
key?.[0] === 'allCircleMembers')
) {
schedule()
}
})
return () => {
clearTimeout(timer)
unsubscribe()
}
}
/** Wipe widget storage so no task data lingers after logout. */
export const clearWidgetData = async () => {
if (!isNative()) return
try {
await WidgetBridge.clear()
} catch (err) {
console.error('Failed to clear widget data', err)
}
}

View File

@@ -2,6 +2,7 @@ import { Preferences } from '@capacitor/preferences'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import { logout, RefreshToken } from './Fetcher'
import { isOAuthExchangeInProgress } from './OAuthExchangeState'
import { offlineDB } from './OfflineDB'
import {
clearAllTokens,
@@ -9,6 +10,8 @@ import {
saveTokens,
} from './TokenStorage'
const OAUTH_EXCHANGE_IN_PROGRESS = 'OAuth exchange in progress'
class ApiClient {
constructor() {
this.customServerURL = `${API_URL}/api/v1`
@@ -46,6 +49,13 @@ class ApiClient {
}
async refreshToken() {
// No session exists yet while an OAuth code exchange is in flight, so there
// is nothing to refresh. Callers must treat this as a non-fatal failure
// (see request()) rather than an expired session.
if (isOAuthExchangeInProgress()) {
return { success: false, error: OAUTH_EXCHANGE_IN_PROGRESS }
}
// Check if refresh token is expired BEFORE attempting refresh
const refreshExpired = await isRefreshTokenExpired()
if (refreshExpired) {
@@ -132,6 +142,14 @@ class ApiClient {
// Helper to avoid repeating cleanup code
async handleLogout() {
// Backstop for every forced-logout path: never tear down the session while
// an OAuth exchange is running, or we clear the tokens it just saved and
// reload the page out from under it.
if (isOAuthExchangeInProgress()) {
console.log('Skipping forced logout: OAuth exchange in progress')
return
}
await clearAllTokens()
try {
await offlineDB.clearAll()
@@ -145,6 +163,13 @@ class ApiClient {
} catch (e) {
console.error('Error clearing image cache on logout', e)
}
try {
// Dynamic import sidesteps the ApiClient <-> WidgetService module cycle
const { clearWidgetData } = await import('../service/WidgetService')
await clearWidgetData()
} catch (e) {
console.error('Error clearing widget data on logout', e)
}
try {
await logout()
} catch (e) {
@@ -223,6 +248,13 @@ class ApiClient {
this.handleLogout()
return null
}
} else if (refreshResult.error === OAUTH_EXCHANGE_IN_PROGRESS) {
// Expected 401: the code exchange hasn't produced tokens yet. Fail
// just this request — logging out here would wipe storage and hard
// navigate to /login, aborting the exchange fetch mid-flight.
queuedPromise.catch(() => {}) // not returned below; keep it handled
this.processQueue(new Error(refreshResult.error), null)
return response
} else if (refreshResult.error === 'Already refreshing') {
// This shouldn't happen since we check isRefreshing above, but handle it anyway
console.log('Already refreshing - waiting for refresh to complete')

View File

@@ -0,0 +1,24 @@
// Tracks whether an OAuth authorization-code exchange is currently in flight.
//
// During that window the app is legitimately unauthenticated: the deep link has
// arrived but Authenticating.jsx has not received tokens yet. Any 401 from an
// unrelated request (background sync, a resumed query) must NOT be treated as an
// expired session — the forced logout it triggers clears storage and does a hard
// `window.location.href = '/login'`, which tears down the page and aborts the
// in-flight code exchange.
let exchangeInProgress = false
export const beginOAuthExchange = () => {
exchangeInProgress = true
}
export const endOAuthExchange = () => {
exchangeInProgress = false
}
// The in-memory flag covers the native deep-link path, where the callback
// arrives while the app is already running. On web the provider redirects with a
// full page load, so nothing has run to set the flag — the pathname check covers
// that case (and doubles as a backstop if the flag is never cleared).
export const isOAuthExchangeInProgress = () =>
exchangeInProgress || window.location.pathname === '/auth/oauth2'

View File

@@ -8,6 +8,7 @@ import { useRef } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { apiClient } from '../../utils/ApiClient'
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
import { GetUserProfile } from '../../utils/Fetcher'
import { saveTokens } from '../../utils/TokenStorage'
import MFAVerificationModal from './MFAVerificationModal'
@@ -25,11 +26,14 @@ const AuthenticationLoading = () => {
useEffect(() => {
if (provider === 'oauth2' && !hasCalledHandleOAuth2.current) {
hasCalledHandleOAuth2.current = true
handleOAuth2()
// Release the guard once the exchange settles, so a stuck flag can never
// suppress a genuine session expiry later on.
handleOAuth2().finally(endOAuthExchange)
} else if (provider !== 'oauth2') {
setMessage('Unknown Authentication Provider')
setSubMessage('Please contact support')
}
return endOAuthExchange
}, [provider])
const getUserProfileAndNavigateToHome = () => {
GetUserProfile().then(data => {

View File

@@ -77,7 +77,7 @@ const MyChores = () => {
const queryClient = useQueryClient()
const { impersonatedUser } = useImpersonateUser()
const Navigate = useNavigate()
const [searchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: projects = [], isLoading: projectsLoading } = useProjects()
const {
@@ -97,11 +97,13 @@ 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)
const [selectedChoreSection, setSelectedChoreSection] = useState(
localStorage.getItem('selectedChoreSection') || 'due_date',
localStorage.getItem('selectedChoreSection') || 'default',
)
const [openChoreSections, setOpenChoreSections] = useState(() => {
try {
@@ -439,6 +441,22 @@ const MyChores = () => {
setSelectedProjectWithCache,
])
// 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])
// Read and apply filters from URL parameters
useEffect(() => {
// Check for filterId (camelCase) or filter_id (snake_case) for advanced filters
@@ -1443,8 +1461,10 @@ const MyChores = () => {
autoFocus={taskInputFocus}
onChoreUpdate={updateChores}
isModalOpen={addTaskModalOpen}
initialMode={addTaskInitialMode}
onClose={forceRefresh => {
setAddTaskModalOpen(false)
setAddTaskInitialMode(null)
if (forceRefresh) {
refetchChores()
}

View File

@@ -61,6 +61,7 @@ import FilterBuilderContent, {
} from './FilterBuilderContent'
import SearchBar from './SearchBar'
import ProjectSelector from '../../components/ProjectSelector'
import CustomFilterChips from './CustomFilterChips'
// ─── sub-components for the Display sheet ────────────────────────────────────
@@ -481,13 +482,6 @@ const ChoreToolbar = ({
// ── display sheet helpers ────────────────────────────────────────────────────
const filterActive = activeFilterId != null || tempConditionCount > 0
const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0
const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0
const displayActive =
selectedGroupBy !== 'default' ||
viewMode !== 'default' ||
projectActive > 0 ||
assigneeActive > 0
const groupByOptions = [
{ value: 'default', label: 'Smart' },
@@ -559,8 +553,8 @@ const ChoreToolbar = ({
{/* Display button — View + Group combined */}
<IconButton
variant={displayActive ? 'solid' : 'outlined'}
color={displayActive ? 'primary' : 'neutral'}
variant='outlined'
color='neutral'
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={() => setDisplaySheetOpen(true)}
@@ -599,8 +593,24 @@ const ChoreToolbar = ({
</Box>
</Box>
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
{hasAnyActive && (
{/* ── Row 2: pinned filters quick access ──────────────────────────────── */}
{/* Stays visible even while a saved filter is active (it self-highlights)
so switching between pinned filters never requires clearing first.
Only steps aside for an ad-hoc/temp condition set, which has no
matching pinned identity to show. */}
{tempConditionCount === 0 && (
<CustomFilterChips
filters={savedFilters}
activeFilterId={activeFilterId}
onFilterClick={onSavedFilterClick}
onFilterDelete={onSavedFilterDelete}
onFilterPin={onSavedFilterPin}
onFilterEdit={onSavedFilterEdit}
/>
)}
{/* ── Row 3: active (temp/ad-hoc) filter chips ──────────────────────────── */}
{tempConditionCount > 0 && (
<ActiveFilterChips
chips={inlineChips}
onOpen={openFilterSheet}

View File

@@ -2,6 +2,7 @@ import {
Check,
Delete,
Edit,
MoreVert,
Settings,
Star,
StarBorder,
@@ -66,158 +67,163 @@ const CustomFilterChips = ({
handleMenuClose()
}
const sortedFilters = [...filters].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
return (b.usageCount || 0) - (a.usageCount || 0)
})
if (sortedFilters.filter(f => f.isPinned).length === 0) return null
const pinnedFilters = filters
.filter(f => f.isPinned)
.sort((a, b) => (b.usageCount || 0) - (a.usageCount || 0))
if (pinnedFilters.length === 0) return null
return (
<Box
sx={{
display: 'flex',
gap: 1,
overflowX: 'auto',
py: 1,
'&::-webkit-scrollbar': {
display: 'none',
},
scrollbarWidth: 'none', // Firefox
msOverflowStyle: 'none', // IE and Edge
}}
>
{sortedFilters.map(filter => {
const isActive = activeFilterId === filter.id
const hasWarning = !filter.isValid
const hasCustomColor = !!filter.color && !hasWarning
const textColor = hasCustomColor
? getTextColorFromBackgroundColor(filter.color)
: undefined
<Box sx={{ position: 'relative' }}>
<Box
sx={{
display: 'flex',
gap: 0.75,
overflowX: 'auto',
py: 1,
pr: 2,
'&::-webkit-scrollbar': {
display: 'none',
},
scrollbarWidth: 'none', // Firefox
msOverflowStyle: 'none', // IE and Edge
}}
>
{pinnedFilters.map(filter => {
const isActive = activeFilterId === filter.id
const hasWarning = !filter.isValid
const badgeColor = filter.color || 'var(--joy-palette-neutral-400)'
const badgeTextColor = filter.color
? getTextColorFromBackgroundColor(filter.color)
: '#ffffff'
const displayCount =
filter.count > 99 ? '99+' : (filter.count ?? 0)
return (
<Tooltip
key={filter.id}
title={
hasWarning
? `Filter has issues: ${filter.validationIssues?.join(', ')}`
: `${filter.description ? filter.description + ' - ' : ''}${filter.count} tasks${filter.overdueCount > 0 ? ` (${filter.overdueCount} overdue)` : ''}`
}
placement='bottom'
>
<div onClick={() => !hasWarning && onFilterClick(filter.id)}>
return (
<Tooltip
key={filter.id}
title={
hasWarning
? `Filter has issues: ${filter.validationIssues?.join(', ')}`
: `${filter.description ? filter.description + ' - ' : ''}${filter.count} tasks${filter.overdueCount > 0 ? ` (${filter.overdueCount} overdue)` : ''}`
}
placement='bottom'
>
<Chip
variant='solid'
variant={isActive ? 'solid' : 'outlined'}
color={hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'}
size='md'
onContextMenu={e => handleContextMenu(e, filter)}
onClick={() => !hasWarning && onFilterClick(filter.id)}
sx={{
cursor: hasWarning ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
px: 1.0,
py: 0.5,
transition: 'background-color 0.15s ease, color 0.15s ease',
px: 1,
height: 32,
display: 'flex',
alignItems: 'center',
opacity: hasWarning ? 0.7 : isActive ? 1 : 0.85,
...(hasCustomColor && {
backgroundColor: `${filter.color} !important`,
color: `${textColor} !important`,
'&:hover': {
backgroundColor: `${filter.color} !important`,
filter: 'brightness(0.95)',
opacity: 1,
},
}),
flexShrink: 0,
fontWeight: isActive ? 600 : 500,
'&:hover': {
backgroundColor: isActive ? undefined : 'neutral.softHoverBg',
},
}}
startDecorator={
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{isActive ? (
<Check
sx={{
fontSize: '1rem',
color: hasCustomColor ? textColor : 'primary.500',
}}
/>
) : (
<Chip
size='sm'
variant='solid'
sx={{
...(hasCustomColor
? {
bgcolor:
textColor === '#FFFFFF'
? '#00000040'
: '#FFFFFF40',
color: textColor,
border: `1px solid ${textColor}30`,
}
: {}),
}}
color={
hasCustomColor
? undefined
: hasWarning
? 'warning'
: 'neutral'
}
>
{filter.count}
</Chip>
)}
</Box>
!hasWarning && (
<Box
sx={{
width: 20,
height: 20,
borderRadius: '50%',
bgcolor: badgeColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{isActive ? (
<Check
sx={{ fontSize: '0.85rem', color: badgeTextColor }}
/>
) : (
<Typography
level='body-xs'
sx={{
fontSize: '0.65rem',
lineHeight: 1,
color: badgeTextColor,
}}
>
{displayCount}
</Typography>
)}
</Box>
)
}
endDecorator={
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={e => {
e.stopPropagation()
handleContextMenu(e, filter)
}}
sx={{
'--IconButton-size': '20px',
ml: 0.25,
opacity: 0.6,
'&:hover': { opacity: 1, backgroundColor: 'transparent' },
}}
>
<MoreVert sx={{ fontSize: '0.95rem' }} />
</IconButton>
}
// endDecorator={
// <Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
// {hasWarning && <Warning sx={{ fontSize: '1rem' }} />}
// {!hasWarning && filter.overdueCount > 0 && (
// <Chip size='sm' color='danger' variant='solid'>
// {filter.overdueCount}
// </Chip>
// )}
// </Box>
// }
>
<Typography
level='body-sm'
fontWeight={isActive ? 'md' : 'normal'}
fontWeight='inherit'
sx={{
whiteSpace: 'nowrap',
maxWidth: 100,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'flex',
alignItems: 'center',
height: '100%',
lineHeight: 1,
...(hasCustomColor && {
color: textColor,
}),
color: 'inherit',
}}
>
{filter.name}
</Typography>
</Chip>
</div>
</Tooltip>
)
})}
</Tooltip>
)
})}
<IconButton
variant='outlined'
size='sm'
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{ borderRadius: 24, flexShrink: 0 }}
onClick={e => {
e.preventDefault()
e.stopPropagation()
navigate('/filters')
}}
>
<Settings sx={{ fontSize: '1.1rem' }} />
</IconButton>
</Box>
{/* Fade hint that more chips are scrollable off the trailing edge */}
<Box
sx={{
borderRadius: 24,
pointerEvents: 'none',
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 24,
background:
'linear-gradient(to right, transparent, var(--joy-palette-background-surface, #fff))',
}}
onClick={e => {
e.preventDefault()
e.stopPropagation()
navigate('/filters')
}}
>
<Settings />
</IconButton>
/>
<Menu
anchorEl={menuAnchor}

View File

@@ -1,4 +1,5 @@
// AutocompleteDropdown.jsx
import { Add } from '@mui/icons-material'
import { Divider, Menu, MenuItem } from '@mui/joy'
import React, { useEffect } from 'react'
@@ -8,6 +9,7 @@ const AutocompleteDropdown = ({
selectedIndex,
onSelectSuggestion,
onMouseEnterSuggestion, // Added for hover selection
onCreateSuggestion, // Called when the "Create new" row is chosen
parentRefer, // Ref to the dropdown element
}) => {
// Scroll selected item into view
@@ -26,9 +28,28 @@ const AutocompleteDropdown = ({
return null // Don't render if no suggestions
}
const filteredOptions = suggestions.options.filter(option => {
if (typeof option === 'string') {
return option.toLowerCase().includes(currentValue.toLowerCase())
}
return option[suggestions.display]
.toLowerCase()
.includes(currentValue.toLowerCase())
})
const trimmedValue = currentValue.trim()
const hasExactMatch = filteredOptions.some(option => {
const optionText = suggestions.display
? option[suggestions.display]
: option
return optionText.toLowerCase() === trimmedValue.toLowerCase()
})
const showCreateOption =
suggestions.creatable && trimmedValue.length > 0 && !hasExactMatch
return (
<Menu
open={Boolean(suggestions?.options?.length)}
open={Boolean(suggestions?.options?.length) || showCreateOption}
ref={dropdownMenuRef}
anchorEl={parentRefer.current}
onClose={() => {}}
@@ -42,45 +63,45 @@ const AutocompleteDropdown = ({
zIndex: 1300,
}}
>
{suggestions?.options
.filter(option => {
if (typeof option === 'string') {
return option.toLowerCase().includes(currentValue.toLowerCase())
}
return option[suggestions.display]
.toLowerCase()
.includes(currentValue.toLowerCase())
})
.map((option, index) => (
{filteredOptions.map((option, index) => (
<MenuItem
key={suggestions.display ? option[suggestions.value] : option}
selected={selectedIndex === index}
onClick={() => onSelectSuggestion(option)}
onMouseEnter={() => onMouseEnterSuggestion(index)} // Update selected index on hover
className={selectedIndex === index ? 'selected' : ''} // Add class for selected item
sx={{
cursor: 'pointer',
backgroundColor: selectedIndex === index ? 'gray.800' : 'inherit',
}}
>
{suggestions.display ? option[suggestions.display] : option}
</MenuItem>
))}
{showCreateOption && (
<>
{filteredOptions.length > 0 && <Divider orientation='horizontal' />}
<MenuItem
key={suggestions.display ? option[suggestions.value] : option}
selected={selectedIndex === index}
onClick={() => onSelectSuggestion(option)}
onMouseEnter={() => onMouseEnterSuggestion(index)} // Update selected index on hover
className={selectedIndex === index ? 'selected' : ''} // Add class for selected item
selected={selectedIndex === filteredOptions.length}
onClick={() => onCreateSuggestion(trimmedValue)}
onMouseEnter={() => onMouseEnterSuggestion(filteredOptions.length)}
className={
selectedIndex === filteredOptions.length ? 'selected' : ''
}
sx={{
cursor: 'pointer',
backgroundColor: selectedIndex === index ? 'gray.800' : 'inherit',
backgroundColor:
selectedIndex === filteredOptions.length
? 'gray.800'
: 'inherit',
gap: 0.5,
}}
>
{suggestions.display ? option[suggestions.display] : option}
<Add fontSize='small' />
Create &quot;{trimmedValue}&quot;
</MenuItem>
))}
<Divider orientation='horizontal' />
{/*
<MenuItem
selected={selectedIndex === suggestions?.options?.length}
onClick={() => {
onSelectSuggestion(currentValue)
}}
sx={{
cursor: 'pointer',
backgroundColor: 'gray.800',
}}
>
<Add />
Add new Label
</MenuItem> */}
</>
)}
</Menu>
)
}

View File

@@ -3,10 +3,12 @@ import { Box, Button, Typography } from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import * as chrono from 'chrono-node'
import moment from 'moment'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { CreateLabel } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import { generateUUID } from '../../utils/UUID'
import { useLabels } from '../Labels/LabelQueries'
@@ -24,7 +26,8 @@ import SmartTaskTitleInput from './SmartTaskTitleInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { localAIService } from '../../service/LocalAIService'
import { TASK_COLOR } from '../../utils/Colors'
import { voiceInputService } from '../../service/VoiceInputService'
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
import AdvancedOptionsSection, {
AdvancedOptionsTrigger,
} from './AdvancedOptionsSection'
@@ -39,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) {
@@ -57,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'
@@ -66,9 +71,22 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
useCircleMembers()
const { isLoading: isProjectsLoading } = useProjects()
const createChoreMutation = useCreateChore()
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
const handleCreateLabel = useCallback(
name => {
const color =
LABEL_COLORS[1 + Math.floor(Math.random() * (LABEL_COLORS.length - 1))]
.value
CreateLabel({ name, color })
.then(() => queryClient.invalidateQueries(['labels']))
.catch(error => console.error('Error creating label:', error))
},
[queryClient],
)
// Get initial project from localStorage (current active project)
const getInitialProject = () => {
const saved = localStorage.getItem('selectedProject')
@@ -89,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)
@@ -121,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,
@@ -378,11 +427,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
if (priority.result) setPriority(parseInt(priority.result, 10))
if (pointsParsed.result) setPoints(pointsParsed.result)
if (labels.result) {
// parseLabels returns array of label objects, extract their IDs
const labelIds = labels.result
.filter(label => label.id) // Only labels with IDs (existing labels)
.map(label => label.id)
setLabelsV2(labelIds)
// parseLabels only returns #mentions matched to an existing label
setLabelsV2(labels.result)
}
if (assigneesResult.isAnyone) {
@@ -486,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],
)
@@ -589,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)
@@ -746,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
@@ -838,6 +949,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
}
: undefined
}
onVoiceClick={
voiceAvailable ? () => setShowVoice(true) : undefined
}
placeholder='Type your task...'
onChange={text => {
setTaskText(text)
@@ -856,6 +970,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
value: 'id',
display: 'name',
options: userLabels ? userLabels : [],
creatable: true,
onCreate: handleCreateLabel,
},
'!': {
value: 'id',
@@ -954,8 +1070,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
/>
<LabelsPickerField
emptyDisplay={pickerEmptyDisplay}
values={labelsV2 || []}
onChange={setLabelsV2}
values={(labelsV2 || []).map(label => label.id)}
onChange={ids =>
setLabelsV2(
(userLabels || []).filter(label => ids.includes(label.id)),
)
}
onClear={() => setLabelsV2([])}
labels={userLabels || []}
/>
@@ -1097,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

@@ -97,64 +97,70 @@ export const parsePriority = inputSentence => {
}
}
export const parseLabels = (inputSentence, userLabels) => {
let sentence = inputSentence.toLowerCase()
const currentLabels = []
const newLabels = []
const matchedRanges = []
const allHighlights = []
// Find all #label patterns in the sentence
// Use [\p{L}\p{N}_]+ to support Unicode letters (including umlauts) and numbers
const labelPattern = /#([\p{L}\p{N}_]+)/giu
const matches = [...inputSentence.matchAll(labelPattern)]
// Labels can contain spaces (e.g. "New Label"), so a plain word-boundary
// regex can't capture them — check each '#' against real label names
// instead, longest name first so "New Label" wins over a label named "New".
const sortedLabels = [...userLabels].sort(
(a, b) => b.name.length - a.name.length,
)
const isWordChar = ch => ch !== undefined && /[\p{L}\p{N}_]/u.test(ch)
for (const match of matches) {
const labelName = match[1]
const fullMatch = match[0]
const startIndex = match.index
const hashPattern = /#/g
let hashMatch
while ((hashMatch = hashPattern.exec(inputSentence)) !== null) {
const startIndex = hashMatch.index
const rest = inputSentence.slice(startIndex + 1)
// Check if this label already exists
const existingLabel = userLabels.find(
label => label.name.toLowerCase() === labelName.toLowerCase(),
)
const existingLabel = sortedLabels.find(label => {
const name = label.name
if (rest.toLowerCase().slice(0, name.length) !== name.toLowerCase()) {
return false
}
// Require a non-word boundary right after the name so "New" doesn't
// match inside a longer typed word like "Newer".
return !isWordChar(rest[name.length])
})
if (existingLabel) {
currentLabels.push(existingLabel)
} else {
// Create a new label object for new labels
newLabels.push({
name: labelName,
color: '#3b82f6', // Default blue color
isNew: true,
})
}
// Unmatched #mentions (no existing label, not created) are left as plain
// text — only #mentions resolving to a real label are extracted/cleaned.
if (!existingLabel) continue
allHighlights.push({
text: fullMatch,
const fullMatch = `#${existingLabel.name}`
currentLabels.push(existingLabel)
matchedRanges.push({
start: startIndex,
end: startIndex + fullMatch.length,
})
// Remove the label from the sentence
sentence = sentence.replace(fullMatch.toLowerCase(), '')
allHighlights.push({
text: inputSentence.slice(startIndex, startIndex + fullMatch.length),
start: startIndex,
end: startIndex + fullMatch.length,
})
hashPattern.lastIndex = startIndex + fullMatch.length
}
const allLabels = [...currentLabels, ...newLabels]
if (currentLabels.length > 0) {
let cleanedSentence = inputSentence
for (const range of matchedRanges
.slice()
.sort((a, b) => b.start - a.start)) {
cleanedSentence =
cleanedSentence.slice(0, range.start) + cleanedSentence.slice(range.end)
}
if (allLabels.length > 0) {
return {
result: allLabels,
newLabels: newLabels,
result: currentLabels,
highlight: allHighlights,
cleanedSentence: inputSentence
.replace(labelPattern, '')
.replace(/\s+/g, ' ')
.trim(),
cleanedSentence: cleanedSentence.replace(/\s+/g, ' ').trim(),
}
}
return {
result: null,
newLabels: [],
cleanedSentence: inputSentence,
}
}

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)
@@ -111,46 +112,71 @@ const SmartTaskTitleInput = ({
setCursorPosition(e.target.selectionStart)
}
const selectSuggestionText = suggestionValue => {
const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}`
onChange(newValue)
titleInputRef.current.value = newValue
setShowSuggestions(false)
const newCursorPosition =
cursorPosition - lastWord.length + suggestionValue.length + 1
titleInputRef.current.setSelectionRange(
newCursorPosition,
newCursorPosition,
)
}
const handleTextareaKeyDown = e => {
if (showSuggestions) {
const currentSuggestions = suggestions[suggestionTrigger].options.filter(
option => {
if (typeof option === 'string') {
return option.toLowerCase().includes(lastWord.toLowerCase())
}
return option[suggestions[suggestionTrigger].display]
.toLowerCase()
.includes(lastWord.toLowerCase())
},
)
const activeSuggestions = suggestions[suggestionTrigger]
const currentSuggestions = activeSuggestions.options.filter(option => {
if (typeof option === 'string') {
return option.toLowerCase().includes(lastWord.toLowerCase())
}
return option[activeSuggestions.display]
.toLowerCase()
.includes(lastWord.toLowerCase())
})
const trimmedWord = lastWord.trim()
const hasExactMatch = currentSuggestions.some(option => {
const optionText = activeSuggestions.display
? option[activeSuggestions.display]
: option
return optionText.toLowerCase() === trimmedWord.toLowerCase()
})
const showCreateOption =
activeSuggestions.creatable && trimmedWord.length > 0 && !hasExactMatch
const optionCount = currentSuggestions.length + (showCreateOption ? 1 : 0)
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (optionCount === 0) return
const newIndex =
e.key === 'ArrowDown'
? (selectedSuggestionIndex + 1) % currentSuggestions.length
: (selectedSuggestionIndex - 1 + currentSuggestions.length) %
currentSuggestions.length
? (selectedSuggestionIndex + 1) % optionCount
: (selectedSuggestionIndex - 1 + optionCount) % optionCount
setSelectedSuggestionIndex(newIndex)
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
if (
showCreateOption &&
selectedSuggestionIndex === currentSuggestions.length
) {
e.preventDefault()
selectSuggestionText(trimmedWord)
activeSuggestions.onCreate?.(trimmedWord)
return
}
const selectedSuggestion = currentSuggestions[selectedSuggestionIndex]
const suggestionValue = suggestions[suggestionTrigger].display
? selectedSuggestion[suggestions[suggestionTrigger].display]
const suggestionValue = activeSuggestions.display
? selectedSuggestion?.[activeSuggestions.display]
: selectedSuggestion
if (suggestionValue) {
const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}`
onChange(newValue)
titleInputRef.current.value = newValue
setShowSuggestions(false)
const newCursorPosition = cursorPosition + suggestionValue.length + 1
titleInputRef.current.setSelectionRange(
newCursorPosition,
newCursorPosition,
)
e.preventDefault()
selectSuggestionText(suggestionValue)
}
} else if (e.key === 'Escape') {
e.preventDefault()
@@ -200,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>
@@ -223,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',
@@ -274,7 +301,7 @@ const SmartTaskTitleInput = ({
{/* Zero-width space to maintain consistent height */}
&#8203;
</div>
{showNativeButtons && (
{showActionButtons && (
<span
style={{
position: 'absolute',
@@ -286,7 +313,7 @@ const SmartTaskTitleInput = ({
gap: '0.1rem',
}}
>
{onPhotoSelected && (
{showPhotoButtons && onPhotoSelected && (
<>
<Tooltip title='Select photo' placement='top' size='sm'>
<IconButton
@@ -308,7 +335,7 @@ const SmartTaskTitleInput = ({
/>
</>
)}
{onScanClick && (
{showPhotoButtons && onScanClick && (
<Tooltip title='Scan to create task' placement='top' size='sm'>
<IconButton
size='sm'
@@ -321,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>
@@ -349,6 +389,10 @@ const SmartTaskTitleInput = ({
)
setShowSuggestions(false)
}}
onCreateSuggestion={name => {
selectSuggestionText(name)
suggestions[suggestionTrigger].onCreate?.(name)
}}
parentRefer={dropdownRef}
/>
)}

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