Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4d46caee4 | ||
|
|
f92063dfe9 | ||
|
|
ce07e467f3 | ||
|
|
f6adc99187 | ||
|
|
950645cc96 | ||
|
|
cb71b6074d | ||
|
|
bb87dc2e14 | ||
|
|
ce49e4afc6 | ||
|
|
ea89f90edb | ||
|
|
8cba22024a | ||
|
|
9044193aa3 | ||
|
|
b145bb422b | ||
|
|
5a4f67d70a | ||
|
|
09c3dbbebc | ||
|
|
ad1f85ffcc | ||
|
|
ec8eb39599 | ||
|
|
adca4c3af9 | ||
|
|
a133b271d0 | ||
|
|
e0257445ef | ||
|
|
65c774d0d0 | ||
|
|
2e5f0a3725 | ||
|
|
ebae9b78b1 | ||
|
|
b73586f81b |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
141
android/app/src/main/java/com/donetick/app/widget/WidgetUi.java
Normal file
141
android/app/src/main/java/com/donetick/app/widget/WidgetUi.java
Normal 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);
|
||||
}
|
||||
}
|
||||
1
android/app/src/main/res/.impeccable/hook.cache.json
Normal file
1
android/app/src/main/res/.impeccable/hook.cache.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version":1,"sessions":{}}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_quick.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_quick.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_today.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_today.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_week.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/widget_preview_week.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -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>
|
||||
11
android/app/src/main/res/drawable/ic_widget_add.xml
Normal file
11
android/app/src/main/res/drawable/ic_widget_add.xml
Normal 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>
|
||||
14
android/app/src/main/res/drawable/ic_widget_mic.xml
Normal file
14
android/app/src/main/res/drawable/ic_widget_mic.xml
Normal 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>
|
||||
15
android/app/src/main/res/drawable/ic_widget_scan.xml
Normal file
15
android/app/src/main/res/drawable/ic_widget_scan.xml
Normal 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>
|
||||
5
android/app/src/main/res/drawable/widget_background.xml
Normal file
5
android/app/src/main/res/drawable/widget_background.xml
Normal 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>
|
||||
5
android/app/src/main/res/drawable/widget_count_bg.xml
Normal file
5
android/app/src/main/res/drawable/widget_count_bg.xml
Normal 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>
|
||||
@@ -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>
|
||||
6
android/app/src/main/res/drawable/widget_ring.xml
Normal file
6
android/app/src/main/res/drawable/widget_ring.xml
Normal 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>
|
||||
6
android/app/src/main/res/drawable/widget_tile_bg.xml
Normal file
6
android/app/src/main/res/drawable/widget_tile_bg.xml
Normal 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>
|
||||
43
android/app/src/main/res/layout/widget_config.xml
Normal file
43
android/app/src/main/res/layout/widget_config.xml
Normal 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>
|
||||
182
android/app/src/main/res/layout/widget_preview_people.xml
Normal file
182
android/app/src/main/res/layout/widget_preview_people.xml
Normal 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>
|
||||
182
android/app/src/main/res/layout/widget_preview_today.xml
Normal file
182
android/app/src/main/res/layout/widget_preview_today.xml
Normal 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>
|
||||
193
android/app/src/main/res/layout/widget_preview_week.xml
Normal file
193
android/app/src/main/res/layout/widget_preview_week.xml
Normal 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>
|
||||
110
android/app/src/main/res/layout/widget_quick_capture.xml
Normal file
110
android/app/src/main/res/layout/widget_quick_capture.xml
Normal 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>
|
||||
12
android/app/src/main/res/layout/widget_row_day_header.xml
Normal file
12
android/app/src/main/res/layout/widget_row_day_header.xml
Normal 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" />
|
||||
37
android/app/src/main/res/layout/widget_row_person.xml
Normal file
37
android/app/src/main/res/layout/widget_row_person.xml
Normal 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>
|
||||
46
android/app/src/main/res/layout/widget_row_task.xml
Normal file
46
android/app/src/main/res/layout/widget_row_task.xml
Normal 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>
|
||||
93
android/app/src/main/res/layout/widget_shell.xml
Normal file
93
android/app/src/main/res/layout/widget_shell.xml
Normal 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>
|
||||
12
android/app/src/main/res/values-night/colors.xml
Normal file
12
android/app/src/main/res/values-night/colors.xml
Normal 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>
|
||||
13
android/app/src/main/res/values/colors.xml
Normal file
13
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
15
android/app/src/main/res/xml/network_security_config.xml
Normal file
15
android/app/src/main/res/xml/network_security_config.xml
Normal 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>
|
||||
15
android/app/src/main/res/xml/widget_people_info.xml
Normal file
15
android/app/src/main/res/xml/widget_people_info.xml
Normal 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" />
|
||||
17
android/app/src/main/res/xml/widget_quick_capture_info.xml
Normal file
17
android/app/src/main/res/xml/widget_quick_capture_info.xml
Normal 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" />
|
||||
17
android/app/src/main/res/xml/widget_today_info.xml
Normal file
17
android/app/src/main/res/xml/widget_today_info.xml
Normal 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" />
|
||||
17
android/app/src/main/res/xml/widget_week_info.xml
Normal file
17
android/app/src/main/res/xml/widget_week_info.xml
Normal 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" />
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 */;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
9
ios/App/App/MainViewController.swift
Normal file
9
ios/App/App/MainViewController.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
46
ios/App/App/WidgetBridgePlugin.swift
Normal file
46
ios/App/App/WidgetBridgePlugin.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
10
ios/App/DonetickWidget/DonetickWidget.entitlements
Normal file
10
ios/App/DonetickWidget/DonetickWidget.entitlements
Normal 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>
|
||||
1107
ios/App/DonetickWidget/DonetickWidget.swift
Normal file
1107
ios/App/DonetickWidget/DonetickWidget.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
187
package-lock.json
generated
@@ -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",
|
||||
|
||||
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
484
patches/@capacitor-community+speech-recognition+7.0.1.patch
Normal file
484
patches/@capacitor-community+speech-recognition+7.0.1.patch
Normal 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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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)
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
416
src/service/VoiceInputService.js
Normal file
416
src/service/VoiceInputService.js
Normal 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()
|
||||
145
src/service/WidgetService.js
Normal file
145
src/service/WidgetService.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
24
src/utils/OAuthExchangeState.js
Normal file
24
src/utils/OAuthExchangeState.js
Normal 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'
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "{trimmedValue}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 */}
|
||||
​
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
|
||||
121
src/views/components/VoiceToTask/VoicePanel.css
Normal file
121
src/views/components/VoiceToTask/VoicePanel.css
Normal 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;
|
||||
}
|
||||
}
|
||||
690
src/views/components/VoiceToTask/VoicePanel.jsx
Normal file
690
src/views/components/VoiceToTask/VoicePanel.jsx
Normal 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 · say “scratch that” 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
|
||||
201
src/views/components/VoiceToTask/parseVoiceTask.js
Normal file
201
src/views/components/VoiceToTask/parseVoiceTask.js
Normal 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
|
||||
}
|
||||
313
src/views/components/VoiceToTask/useVoiceToTask.js
Normal file
313
src/views/components/VoiceToTask/useVoiceToTask.js
Normal 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,
|
||||
}
|
||||
}
|
||||
252
src/views/components/VoiceToTask/voiceNormalizer.js
Normal file
252
src/views/components/VoiceToTask/voiceNormalizer.js
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user